Merge pull request #243 from ouchadam/feature/mute-room-notifications

Mute room notifications
This commit is contained in:
Adam Brown 2022-11-02 14:52:38 +00:00 committed by GitHub
commit 1237cd21a0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 592 additions and 324 deletions

View File

@ -36,6 +36,8 @@ interface ChatEngine : TaskRunner {
fun pushHandler(): PushHandler fun pushHandler(): PushHandler
suspend fun muteRoom(roomId: RoomId)
suspend fun unmuteRoom(roomId: RoomId)
} }
interface TaskRunner { interface TaskRunner {

View File

@ -13,7 +13,8 @@ typealias InviteState = List<RoomInvite>
data class DirectoryItem( data class DirectoryItem(
val overview: RoomOverview, val overview: RoomOverview,
val unreadCount: UnreadCount, val unreadCount: UnreadCount,
val typing: Typing? val typing: Typing?,
val isMuted: Boolean,
) )
data class RoomOverview( data class RoomOverview(
@ -87,7 +88,8 @@ sealed interface ImportResult {
data class MessengerPageState( data class MessengerPageState(
val self: UserId, val self: UserId,
val roomState: RoomState, val roomState: RoomState,
val typing: Typing? val typing: Typing?,
val isMuted: Boolean,
) )
data class RoomState( data class RoomState(

View File

@ -6,8 +6,9 @@ import app.dapk.st.matrix.common.*
fun aMessengerState( fun aMessengerState(
self: UserId = aUserId(), self: UserId = aUserId(),
roomState: RoomState, roomState: RoomState,
typing: Typing? = null typing: Typing? = null,
) = MessengerPageState(self, roomState, typing) isMuted: Boolean = false,
) = MessengerPageState(self, roomState, typing, isMuted)
fun aRoomOverview( fun aRoomOverview(
roomId: RoomId = aRoomId(), roomId: RoomId = aRoomId(),

View File

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

View File

@ -18,3 +18,4 @@ suspend fun CachedPreferences.readBoolean(key: String, defaultValue: Boolean) =
suspend fun Preferences.readBoolean(key: String) = this.readString(key)?.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())

View File

@ -11,7 +11,6 @@ import app.dapk.state.Store
import app.dapk.state.createStore import app.dapk.state.createStore
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.launch
class StateViewModel<S, E>( class StateViewModel<S, E>(
reducerFactory: ReducerFactory<S>, reducerFactory: ReducerFactory<S>,
@ -32,7 +31,7 @@ class StateViewModel<S, E>(
} }
override fun dispatch(action: Action) { override fun dispatch(action: Action) {
viewModelScope.launch { store.dispatch(action) } store.dispatch(action)
} }
} }

View File

@ -11,8 +11,8 @@ fun <S> createStore(reducerFactory: ReducerFactory<S>, coroutineScope: Coroutine
private val scope = createScope(coroutineScope, this) private val scope = createScope(coroutineScope, this)
private val reducer = reducerFactory.create(scope) private val reducer = reducerFactory.create(scope)
override suspend fun dispatch(action: Action) { override fun dispatch(action: Action) {
scope.coroutineScope.launch { coroutineScope.launch {
state = reducer.reduce(action).also { nextState -> state = reducer.reduce(action).also { nextState ->
if (nextState != state) { if (nextState != state) {
subscribers.forEach { it.invoke(nextState) } subscribers.forEach { it.invoke(nextState) }
@ -35,7 +35,7 @@ interface ReducerFactory<S> {
} }
fun interface Reducer<S> { fun interface Reducer<S> {
suspend fun reduce(action: Action): S fun reduce(action: Action): S
} }
private fun <S> createScope(coroutineScope: CoroutineScope, store: Store<S>) = object : ReducerScope<S> { private fun <S> createScope(coroutineScope: CoroutineScope, store: Store<S>) = object : ReducerScope<S> {
@ -45,7 +45,7 @@ private fun <S> createScope(coroutineScope: CoroutineScope, store: Store<S>) = o
} }
interface Store<S> { interface Store<S> {
suspend fun dispatch(action: Action) fun dispatch(action: Action)
fun getState(): S fun getState(): S
fun subscribe(subscriber: (S) -> Unit) fun subscribe(subscriber: (S) -> Unit)
} }
@ -82,14 +82,18 @@ fun <S> createReducer(
actionHandlers.fold(acc) { acc, handler -> actionHandlers.fold(acc) { acc, handler ->
when (handler) { when (handler) {
is ActionHandler.Async -> { is ActionHandler.Async -> {
scope.coroutineScope.launch {
handler.handler.invoke(scope, action) handler.handler.invoke(scope, action)
}
acc acc
} }
is ActionHandler.Sync -> handler.handler.invoke(action, acc) is ActionHandler.Sync -> handler.handler.invoke(action, acc)
is ActionHandler.Delegate -> when (val next = handler.handler.invoke(scope, action)) { is ActionHandler.Delegate -> when (val next = handler.handler.invoke(scope, action)) {
is ActionHandler.Async -> { is ActionHandler.Async -> {
scope.coroutineScope.launch {
next.handler.invoke(scope, action) next.handler.invoke(scope, action)
}
acc acc
} }

View File

@ -53,7 +53,7 @@ class ReducerTestScope<S, E>(
} }
private val reducer: Reducer<S> = reducerFactory.create(reducerScope) private val reducer: Reducer<S> = reducerFactory.create(reducerScope)
override suspend fun reduce(action: Action) = reducer.reduce(action).also { override fun reduce(action: Action) = reducer.reduce(action).also {
capturedResult = it capturedResult = it
} }

View File

@ -13,6 +13,7 @@ import app.dapk.st.domain.preference.CachingPreferences
import app.dapk.st.domain.preference.PropertyCache import app.dapk.st.domain.preference.PropertyCache
import app.dapk.st.domain.profile.ProfilePersistence import app.dapk.st.domain.profile.ProfilePersistence
import app.dapk.st.domain.push.PushTokenRegistrarPreferences 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.OverviewPersistence
import app.dapk.st.domain.sync.RoomPersistence import app.dapk.st.domain.sync.RoomPersistence
import app.dapk.st.matrix.common.CredentialsStore import app.dapk.st.matrix.common.CredentialsStore
@ -33,8 +34,18 @@ class StoreModule(
private val coroutineDispatchers: CoroutineDispatchers, private val coroutineDispatchers: CoroutineDispatchers,
) { ) {
private val muteableStore by unsafeLazy { MutedStorePersistence(database, coroutineDispatchers) }
fun overviewStore(): OverviewStore = OverviewPersistence(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 credentialsStore(): CredentialsStore = CredentialsPreferences(credentialPreferences)
fun syncStore(): SyncStore = SyncTokenPreferences(preferences) fun syncStore(): SyncStore = SyncTokenPreferences(preferences)
fun filterStore(): FilterStore = FilterPreferences(preferences) fun filterStore(): FilterStore = FilterPreferences(preferences)

View File

@ -4,6 +4,7 @@ import app.dapk.st.core.CachedPreferences
import app.dapk.st.core.Preferences import app.dapk.st.core.Preferences
class CachingPreferences(private val cache: PropertyCache, private val preferences: Preferences) : CachedPreferences { class CachingPreferences(private val cache: PropertyCache, private val preferences: Preferences) : CachedPreferences {
override suspend fun store(key: String, value: String) { override suspend fun store(key: String, value: String) {
cache.setValue(key, value) cache.setValue(key, value)
preferences.store(key, value) preferences.store(key, value)

View File

@ -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<Set<RoomId>>(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<Set<RoomId>> = database.mutedRoomQueries.select()
.asFlow()
.mapToList()
.map { it.map { RoomId(it) }.toSet() }
}

View File

@ -4,12 +4,11 @@ import app.dapk.db.DapkDb
import app.dapk.db.model.RoomEventQueries import app.dapk.db.model.RoomEventQueries
import app.dapk.st.core.CoroutineDispatchers import app.dapk.st.core.CoroutineDispatchers
import app.dapk.st.core.withIoContext 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.EventId
import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.sync.RoomEvent import app.dapk.st.matrix.sync.*
import app.dapk.st.matrix.sync.RoomOverview import com.squareup.sqldelight.Query
import app.dapk.st.matrix.sync.RoomState
import app.dapk.st.matrix.sync.RoomStore
import com.squareup.sqldelight.runtime.coroutines.asFlow import com.squareup.sqldelight.runtime.coroutines.asFlow
import com.squareup.sqldelight.runtime.coroutines.mapToList import com.squareup.sqldelight.runtime.coroutines.mapToList
import com.squareup.sqldelight.runtime.coroutines.mapToOneNotNull import com.squareup.sqldelight.runtime.coroutines.mapToOneNotNull
@ -25,7 +24,8 @@ internal class RoomPersistence(
private val database: DapkDb, private val database: DapkDb,
private val overviewPersistence: OverviewPersistence, private val overviewPersistence: OverviewPersistence,
private val coroutineDispatchers: CoroutineDispatchers, private val coroutineDispatchers: CoroutineDispatchers,
) : RoomStore { private val muteableStore: MutedStorePersistence,
) : RoomStore, MuteableStore by muteableStore {
override suspend fun persist(roomId: RoomId, events: List<RoomEvent>) { override suspend fun persist(roomId: RoomId, events: List<RoomEvent>) {
coroutineDispatchers.withIoContext { coroutineDispatchers.withIoContext {
@ -57,10 +57,8 @@ internal class RoomPersistence(
}.distinctUntilChanged() }.distinctUntilChanged()
return database.roomEventQueries.selectRoom(roomId.value) return database.roomEventQueries.selectRoom(roomId.value)
.asFlow() .distinctFlowList()
.mapToList()
.map { it.map { json.decodeFromString(RoomEvent.serializer(), it) } } .map { it.map { json.decodeFromString(RoomEvent.serializer(), it) } }
.distinctUntilChanged()
.combine(overviewFlow) { events, overview -> .combine(overviewFlow) { events, overview ->
RoomState(overview, events) RoomState(overview, events)
} }
@ -92,9 +90,7 @@ internal class RoomPersistence(
override fun observeUnread(): Flow<Map<RoomOverview, List<RoomEvent>>> { override fun observeUnread(): Flow<Map<RoomOverview, List<RoomEvent>>> {
return database.roomEventQueries.selectAllUnread() return database.roomEventQueries.selectAllUnread()
.asFlow() .distinctFlowList()
.mapToList()
.distinctUntilChanged()
.map { .map {
it.groupBy { RoomId(it.room_id) } it.groupBy { RoomId(it.room_id) }
.mapKeys { overviewPersistence.retrieve(it.key)!! } .mapKeys { overviewPersistence.retrieve(it.key)!! }
@ -116,6 +112,22 @@ internal class RoomPersistence(
} }
} }
override fun observeNotMutedUnread(): Flow<Map<RoomOverview, List<RoomEvent>>> {
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 <T : Any> Query<T>.distinctFlowList() = this.asFlow().mapToList().distinctUntilChanged()
override suspend fun markRead(roomId: RoomId) { override suspend fun markRead(roomId: RoomId) {
coroutineDispatchers.withIoContext { coroutineDispatchers.withIoContext {
database.unreadEventQueries.removeRead(room_id = roomId.value) database.unreadEventQueries.removeRead(room_id = roomId.value)

View File

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

View File

@ -34,6 +34,16 @@ INNER JOIN dbRoomEvent ON dbUnreadEvent.event_id = dbRoomEvent.event_id
ORDER BY dbRoomEvent.timestamp_utc DESC ORDER BY dbRoomEvent.timestamp_utc DESC
LIMIT 100; 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: remove:
DELETE FROM dbRoomEvent DELETE FROM dbRoomEvent
WHERE room_id = ?; WHERE room_id = ?;

View File

@ -16,7 +16,6 @@ FROM dbRoomMember
WHERE room_id = ? WHERE room_id = ?
LIMIT ?; LIMIT ?;
insert: insert:
INSERT OR REPLACE INTO dbRoomMember(user_id, room_id, blob) INSERT OR REPLACE INTO dbRoomMember(user_id, room_id, blob)
VALUES (?, ?, ?); VALUES (?, ?, ?);

View File

@ -2,6 +2,7 @@ package app.dapk.st.directory
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.util.Log
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* 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.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape 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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.* import androidx.compose.runtime.*
@ -198,15 +206,37 @@ private fun DirectoryItem(room: DirectoryItem, onClick: (RoomId) -> Unit, clock:
) )
} }
if (hasUnread) { if (hasUnread || room.isMuted) {
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Box(modifier = Modifier.weight(1f)) { Box(modifier = Modifier.weight(1f)) {
body(overview, secondaryText, room.typing) body(overview, secondaryText, room.typing)
} }
if (hasUnread) {
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
Box(Modifier.align(Alignment.CenterVertically)) { 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)
}
}
}
}
}
@Composable
private fun BoxScope.UnreadCircle(room: DirectoryItem) {
Box( Box(
Modifier Modifier.Companion
.align(Alignment.Center) .align(Alignment.Center)
.background(color = MaterialTheme.colorScheme.primary, shape = CircleShape) .background(color = MaterialTheme.colorScheme.primary, shape = CircleShape)
.size(22.dp), .size(22.dp),
@ -227,14 +257,6 @@ private fun DirectoryItem(room: DirectoryItem, onClick: (RoomId) -> Unit, clock:
color = MaterialTheme.colorScheme.onPrimary color = MaterialTheme.colorScheme.onPrimary
) )
} }
}
}
} else {
body(overview, secondaryText, room.typing)
}
}
}
}
} }
@Composable @Composable

View File

@ -21,7 +21,7 @@ internal fun directoryReducer(
multi(ComponentLifecycle::class) { action -> multi(ComponentLifecycle::class) { action ->
when (action) { when (action) {
ComponentLifecycle.OnVisible -> async { _ -> 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 }) shortcutHandler.onDirectoryUpdate(it.map { it.overview })
when (it.isEmpty()) { when (it.isEmpty()) {
true -> dispatch(DirectoryStateChange.Empty) true -> dispatch(DirectoryStateChange.Empty)

View File

@ -2,7 +2,6 @@ package app.dapk.st.directory
import app.dapk.st.core.JobBag import app.dapk.st.core.JobBag
import app.dapk.st.directory.state.* import app.dapk.st.directory.state.*
import app.dapk.st.engine.DirectoryItem
import app.dapk.st.engine.UnreadCount import app.dapk.st.engine.UnreadCount
import fake.FakeChatEngine import fake.FakeChatEngine
import fixture.aRoomOverview import fixture.aRoomOverview
@ -13,7 +12,7 @@ import test.expect
import test.testReducer import test.testReducer
private val AN_OVERVIEW = aRoomOverview() 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 { class DirectoryReducerTest {
@ -38,7 +37,7 @@ class DirectoryReducerTest {
@Test @Test
fun `given directory content, when Visible, then updates shortcuts and dispatches room state`() = runReducerTest { fun `given directory content, when Visible, then updates shortcuts and dispatches room state`() = runReducerTest {
fakeShortcutHandler.instance.expectUnit { it.onDirectoryUpdate(listOf(AN_OVERVIEW)) } 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))) fakeChatEngine.givenDirectory().returns(flowOf(listOf(AN_OVERVIEW_STATE)))
reduce(ComponentLifecycle.OnVisible) reduce(ComponentLifecycle.OnVisible)
@ -49,7 +48,7 @@ class DirectoryReducerTest {
@Test @Test
fun `given no directory content, when Visible, then updates shortcuts and dispatches empty state`() = runReducerTest { fun `given no directory content, when Visible, then updates shortcuts and dispatches empty state`() = runReducerTest {
fakeShortcutHandler.instance.expectUnit { it.onDirectoryUpdate(emptyList()) } 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())) fakeChatEngine.givenDirectory().returns(flowOf(emptyList()))
reduce(ComponentLifecycle.OnVisible) reduce(ComponentLifecycle.OnVisible)

View File

@ -87,9 +87,20 @@ internal fun MessengerScreen(
Column { Column {
Toolbar(onNavigate = { navigator.navigate.upToHome() }, roomTitle, actions = { Toolbar(onNavigate = { navigator.navigate.upToHome() }, roomTitle, actions = {
// OverflowMenu { state.roomState.takeIfContent()?.let {
// DropdownMenuItem(text = { Text("Settings", color = MaterialTheme.colorScheme.onSecondaryContainer) }, onClick = {}) 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) { when (state.composerState) {

View File

@ -10,6 +10,11 @@ sealed interface ScreenAction : Action {
data class CopyToClipboard(val model: BubbleModel) : ScreenAction data class CopyToClipboard(val model: BubbleModel) : ScreenAction
object SendMessage : ScreenAction object SendMessage : ScreenAction
object OpenGalleryPicker : ScreenAction object OpenGalleryPicker : ScreenAction
sealed interface Notifications : ScreenAction {
object Mute : Notifications
object Unmute : Notifications
}
} }
sealed interface ComponentLifecycle : Action { sealed interface ComponentLifecycle : Action {
@ -18,7 +23,8 @@ sealed interface ComponentLifecycle : Action {
} }
sealed interface MessagesStateChange : 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 { sealed interface ComposerStateChange : Action {

View File

@ -42,7 +42,8 @@ internal fun messengerReducer(
val state = getState() val state = getState()
when (action) { when (action) {
is ComponentLifecycle.Visible -> { is ComponentLifecycle.Visible -> {
jobBag.add("messages", chatEngine.messages(state.roomId, disableReadReceipts = messageOptionsStore.isReadReceiptsDisabled()) jobBag.replace(
"messages", chatEngine.messages(state.roomId, disableReadReceipts = messageOptionsStore.isReadReceiptsDisabled())
.onEach { dispatch(MessagesStateChange.Content(it)) } .onEach { dispatch(MessagesStateChange.Content(it)) }
.launchIn(coroutineScope) .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, timestampUtc = this.utcTimestamp,
) )
private fun initialComposerState(initialAttachments: List<MessageAttachment>?) = initialAttachments private fun initialComposerState(initialAttachments: List<MessageAttachment>?) = initialAttachments
?.takeIf { it.isNotEmpty() } ?.takeIf { it.isNotEmpty() }
?.let { ComposerState.Attachments(it, null) } ?.let { ComposerState.Attachments(it, null) }

View File

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

View File

@ -27,6 +27,7 @@ private const val READ_RECEIPTS_ARE_DISABLED = true
private val A_ROOM_ID = aRoomId("messenger state room id") private val A_ROOM_ID = aRoomId("messenger state room id")
private const val A_MESSAGE_CONTENT = "message content" private const val A_MESSAGE_CONTENT = "message content"
private val AN_EVENT_ID = anEventId("state event") 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_SELF_ID = aUserId("self")
private val A_MESSENGER_PAGE_STATE = aMessengerStateWithEvent(AN_EVENT_ID, A_SELF_ID) private val A_MESSENGER_PAGE_STATE = aMessengerStateWithEvent(AN_EVENT_ID, A_SELF_ID)
private val A_MESSAGE_ATTACHMENT = MessageAttachment(AndroidUri("a-uri"), MimeType.Image) private val A_MESSAGE_ATTACHMENT = MessageAttachment(AndroidUri("a-uri"), MimeType.Image)
@ -101,7 +102,7 @@ class MessengerReducerTest {
@Test @Test
fun `given messages emits state, when Visible, then dispatches content`() = runReducerTest { 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) fakeMessageOptionsStore.givenReadReceiptsDisabled().returns(READ_RECEIPTS_ARE_DISABLED)
val state = aMessengerStateWithEvent(AN_EVENT_ID, A_SELF_ID) val state = aMessengerStateWithEvent(AN_EVENT_ID, A_SELF_ID)
fakeChatEngine.givenMessages(A_ROOM_ID, READ_RECEIPTS_ARE_DISABLED).returns(flowOf(state)) fakeChatEngine.givenMessages(A_ROOM_ID, READ_RECEIPTS_ARE_DISABLED).returns(flowOf(state))

View File

@ -22,13 +22,15 @@ internal class DirectoryUseCase(
overviewDatasource(), overviewDatasource(),
messageService.localEchos(), messageService.localEchos(),
roomStore.observeUnreadCountById(), roomStore.observeUnreadCountById(),
syncService.events() syncService.events(),
) { overviewState, localEchos, unread, events -> roomStore.observeMuted(),
) { overviewState, localEchos, unread, events, muted ->
overviewState.mergeWithLocalEchos(localEchos, userId).map { roomOverview -> overviewState.mergeWithLocalEchos(localEchos, userId).map { roomOverview ->
DirectoryItem( DirectoryItem(
overview = roomOverview, overview = roomOverview,
unreadCount = UnreadCount(unread[roomOverview.roomId] ?: 0), unreadCount = UnreadCount(unread[roomOverview.roomId] ?: 0),
typing = events.filterIsInstance<Typing>().firstOrNull { it.roomId == roomOverview.roomId }?.engine() typing = events.filterIsInstance<Typing>().firstOrNull { it.roomId == roomOverview.roomId }?.engine(),
isMuted = muted.contains(roomOverview.roomId),
) )
} }
} }

View File

@ -3,30 +3,28 @@ package app.dapk.st.engine
import app.dapk.st.core.Base64 import app.dapk.st.core.Base64
import app.dapk.st.core.BuildMeta import app.dapk.st.core.BuildMeta
import app.dapk.st.core.CoroutineDispatchers import app.dapk.st.core.CoroutineDispatchers
import app.dapk.st.core.SingletonFlows
import app.dapk.st.core.extensions.ErrorTracker import app.dapk.st.core.extensions.ErrorTracker
import app.dapk.st.matrix.MatrixClient import app.dapk.st.matrix.MatrixClient
import app.dapk.st.matrix.MatrixTaskRunner import app.dapk.st.matrix.MatrixTaskRunner
import app.dapk.st.matrix.auth.DeviceDisplayNameGenerator import app.dapk.st.matrix.auth.DeviceDisplayNameGenerator
import app.dapk.st.matrix.auth.authService import app.dapk.st.matrix.auth.authService
import app.dapk.st.matrix.auth.installAuthService import app.dapk.st.matrix.common.CredentialsStore
import app.dapk.st.matrix.common.* import app.dapk.st.matrix.common.MatrixLogger
import app.dapk.st.matrix.crypto.* 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.KnownDeviceStore
import app.dapk.st.matrix.device.deviceService import app.dapk.st.matrix.message.BackgroundScheduler
import app.dapk.st.matrix.device.installEncryptionService import app.dapk.st.matrix.message.LocalEchoStore
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.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.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.*
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.OlmStore
import app.dapk.st.olm.OlmWrapper
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@ -114,6 +112,10 @@ class MatrixEngine internal constructor(
override fun pushHandler() = matrixPushHandler.value 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 { override suspend fun runTask(task: ChatEngineTask): TaskRunner.TaskResult {
return when (val result = matrix.value.run(MatrixTaskRunner.MatrixTask(task.type, task.jsonPayload))) { return when (val result = matrix.value.run(MatrixTaskRunner.MatrixTask(task.type, task.jsonPayload))) {
is MatrixTaskRunner.TaskResult.Failure -> TaskRunner.TaskResult.Failure(result.canRetry) is MatrixTaskRunner.TaskResult.Failure -> TaskRunner.TaskResult.Failure(result.canRetry)
@ -209,222 +211,4 @@ class MatrixEngine internal constructor(
} }
private fun <T> unsafeLazy(initializer: () -> T): Lazy<T> = lazy(mode = LazyThreadSafetyMode.NONE, initializer = initializer)
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<UserId>) = roomService.findMembers(roomId, userIds)
override suspend fun findSummary(roomId: RoomId) = roomService.findMembersSummary(roomId)
override suspend fun insert(roomId: RoomId, members: List<RoomMember>) = roomService.insertMembers(roomId, members)
}
},
errorTracker = errorTracker,
coroutineDispatchers = coroutineDispatchers,
)
installPushService(credentialsStore)
}
}
}
fun <T> unsafeLazy(initializer: () -> T): Lazy<T> = lazy(mode = LazyThreadSafetyMode.NONE, initializer = initializer)

View File

@ -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<UserId>) = roomService.findMembers(roomId, userIds)
override suspend fun findSummary(roomId: RoomId) = roomService.findMembersSummary(roomId)
override suspend fun insert(roomId: RoomId, members: List<RoomMember>) = 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<Boolean> = roomStore.observeMuted().map { it.contains(roomId) }.distinctUntilChanged()
}
}

View File

@ -16,7 +16,7 @@ internal typealias ObserveUnreadNotificationsUseCase = () -> Flow<UnreadNotifica
class ObserveUnreadNotificationsUseCaseImpl(private val roomStore: RoomStore) : ObserveUnreadNotificationsUseCase { class ObserveUnreadNotificationsUseCaseImpl(private val roomStore: RoomStore) : ObserveUnreadNotificationsUseCase {
override fun invoke(): Flow<UnreadNotifications> { override fun invoke(): Flow<UnreadNotifications> {
return roomStore.observeUnread() return roomStore.observeNotMutedUnread()
.mapWithDiff() .mapWithDiff()
.avoidShowingPreviousNotificationsOnLaunch() .avoidShowingPreviousNotificationsOnLaunch()
.onlyRenderableChanges() .onlyRenderableChanges()

View File

@ -23,8 +23,9 @@ internal class TimelineUseCaseImpl(
return combine( return combine(
roomDatasource(roomId), roomDatasource(roomId),
messageService.localEchos(roomId), messageService.localEchos(roomId),
syncService.events(roomId) syncService.events(roomId),
) { roomState, localEchos, events -> roomService.observeIsMuted(roomId),
) { roomState, localEchos, events, isMuted ->
MessengerPageState( MessengerPageState(
roomState = when { roomState = when {
localEchos.isEmpty() -> roomState localEchos.isEmpty() -> roomState
@ -38,6 +39,7 @@ internal class TimelineUseCaseImpl(
}, },
typing = events.filterIsInstance<SyncService.SyncEvent.Typing>().firstOrNull { it.roomId == roomId }?.engine(), typing = events.filterIsInstance<SyncService.SyncEvent.Typing>().firstOrNull { it.roomId == roomId }?.engine(),
self = userId, self = userId,
isMuted = isMuted,
) )
} }
} }

View File

@ -61,7 +61,7 @@ class ObserveUnreadRenderNotificationsUseCaseTest {
@Test @Test
fun `given initial unreads, when receiving new message, then emits all messages`() = runTest { 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)) flowOf(A_ROOM_OVERVIEW.withUnreads(A_MESSAGE), A_ROOM_OVERVIEW.withUnreads(A_MESSAGE, A_MESSAGE_2))
) )
@ -74,7 +74,7 @@ class ObserveUnreadRenderNotificationsUseCaseTest {
@Test @Test
fun `given initial unreads, when reading a message, then emits nothing`() = runTest { 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)) 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 @Test
fun `given new and then historical message, when reading a message, then only emits the latest`() = runTest { fun `given new and then historical message, when reading a message, then only emits the latest`() = runTest {
fakeRoomStore.givenUnreadEvents( fakeRoomStore.givenNotMutedUnreadEvents(
flowOf( flowOf(
NO_UNREADS, NO_UNREADS,
A_ROOM_OVERVIEW.withUnreads(A_MESSAGE), A_ROOM_OVERVIEW.withUnreads(A_MESSAGE),
@ -105,7 +105,7 @@ class ObserveUnreadRenderNotificationsUseCaseTest {
@Test @Test
fun `given initial unreads, when reading a duplicate unread, then emits nothing`() = runTest { 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)) 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<MatrixRoomOverview, List<MatrixRoomEvent>>) = private fun givenNoInitialUnreads(vararg unreads: Map<MatrixRoomOverview, List<MatrixRoomEvent>>) =
fakeRoomStore.givenUnreadEvents(flowOf(NO_UNREADS, *unreads)) fakeRoomStore.givenNotMutedUnreadEvents(flowOf(NO_UNREADS, *unreads))
} }
private fun Map<MatrixRoomOverview, List<MatrixRoomEvent>>.engine() = this private fun Map<MatrixRoomOverview, List<MatrixRoomEvent>>.engine() = this

View File

@ -22,6 +22,7 @@ import org.junit.Test
import test.FlowTestObserver import test.FlowTestObserver
import test.delegateReturn import test.delegateReturn
private const val IS_ROOM_MUTED = false
private val A_ROOM_ID = aRoomId() private val A_ROOM_ID = aRoomId()
private val AN_USER_ID = aUserId() private val AN_USER_ID = aUserId()
private val A_ROOM_STATE = aMatrixRoomState() 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()) 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) timelineUseCase.invoke(A_ROOM_ID, AN_USER_ID)
.test(this) .test(this)
.assertValues( .assertValues(
@ -103,6 +105,7 @@ class TimelineUseCaseTest {
fakeSyncService.givenRoom(A_ROOM_ID).returns(flowOf(roomState)) fakeSyncService.givenRoom(A_ROOM_ID).returns(flowOf(roomState))
fakeMessageService.givenEchos(A_ROOM_ID).returns(flowOf(echos)) fakeMessageService.givenEchos(A_ROOM_ID).returns(flowOf(echos))
fakeSyncService.givenEvents(A_ROOM_ID).returns(flowOf(events)) 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() { class FakeRoomService : RoomService by mockk() {
fun givenFindMember(roomId: RoomId, userId: UserId) = coEvery { findMember(roomId, userId) }.delegateReturn() fun givenFindMember(roomId: RoomId, userId: UserId) = coEvery { findMember(roomId, userId) }.delegateReturn()
fun givenMuted(roomId: RoomId) = every { observeIsMuted(roomId) }.delegateReturn()
} }
fun aMessengerState( fun aMessengerState(
self: UserId = aUserId(), self: UserId = aUserId(),
roomState: app.dapk.st.engine.RoomState, roomState: app.dapk.st.engine.RoomState,
typing: Typing? = null typing: Typing? = null,
) = MessengerPageState(self, roomState, typing) isMuted: Boolean = IS_ROOM_MUTED,
) = MessengerPageState(self, roomState, typing, isMuted)

View File

@ -5,10 +5,8 @@ import app.dapk.st.matrix.common.EventId
import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.common.RoomMember import app.dapk.st.matrix.common.RoomMember
import app.dapk.st.matrix.common.UserId import app.dapk.st.matrix.common.UserId
import app.dapk.st.matrix.room.internal.DefaultRoomService import app.dapk.st.matrix.room.internal.*
import app.dapk.st.matrix.room.internal.RoomInviteRemover import kotlinx.coroutines.flow.Flow
import app.dapk.st.matrix.room.internal.RoomMembers
import app.dapk.st.matrix.room.internal.RoomMembersCache
private val SERVICE_KEY = RoomService::class private val SERVICE_KEY = RoomService::class
@ -27,6 +25,10 @@ interface RoomService : MatrixService {
suspend fun joinRoom(roomId: RoomId) suspend fun joinRoom(roomId: RoomId)
suspend fun rejectJoinRoom(roomId: RoomId) suspend fun rejectJoinRoom(roomId: RoomId)
suspend fun muteRoom(roomId: RoomId)
suspend fun unmuteRoom(roomId: RoomId)
fun observeIsMuted(roomId: RoomId): Flow<Boolean>
data class JoinedMember( data class JoinedMember(
val userId: UserId, val userId: UserId,
val displayName: String?, val displayName: String?,
@ -39,6 +41,7 @@ fun MatrixServiceInstaller.installRoomService(
memberStore: MemberStore, memberStore: MemberStore,
roomMessenger: ServiceDepFactory<RoomMessenger>, roomMessenger: ServiceDepFactory<RoomMessenger>,
roomInviteRemover: RoomInviteRemover, roomInviteRemover: RoomInviteRemover,
singleRoomStore: SingleRoomStore,
): InstallExtender<RoomService> { ): InstallExtender<RoomService> {
return this.install { (httpClient, _, services, logger) -> return this.install { (httpClient, _, services, logger) ->
SERVICE_KEY to DefaultRoomService( SERVICE_KEY to DefaultRoomService(
@ -46,7 +49,8 @@ fun MatrixServiceInstaller.installRoomService(
logger, logger,
RoomMembers(memberStore, RoomMembersCache()), RoomMembers(memberStore, RoomMembersCache()),
roomMessenger.create(services), roomMessenger.create(services),
roomInviteRemover roomInviteRemover,
singleRoomStore,
) )
} }
} }

View File

@ -10,6 +10,7 @@ import app.dapk.st.matrix.room.RoomMessenger
import app.dapk.st.matrix.room.RoomService import app.dapk.st.matrix.room.RoomService
import io.ktor.client.plugins.* import io.ktor.client.plugins.*
import io.ktor.http.* import io.ktor.http.*
import kotlinx.coroutines.flow.Flow
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -19,6 +20,7 @@ class DefaultRoomService(
private val roomMembers: RoomMembers, private val roomMembers: RoomMembers,
private val roomMessenger: RoomMessenger, private val roomMessenger: RoomMessenger,
private val roomInviteRemover: RoomInviteRemover, private val roomInviteRemover: RoomInviteRemover,
private val singleRoomStore: SingleRoomStore,
) : RoomService { ) : RoomService {
override suspend fun joinedMembers(roomId: RoomId): List<RoomService.JoinedMember> { override suspend fun joinedMembers(roomId: RoomId): List<RoomService.JoinedMember> {
@ -82,6 +84,7 @@ class DefaultRoomService(
} else { } else {
throw it throw it
} }
} }
else -> throw it else -> throw it
@ -90,6 +93,22 @@ class DefaultRoomService(
) )
roomInviteRemover.remove(roomId) 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<Boolean> = singleRoomStore.isMuted(roomId)
}
interface SingleRoomStore {
suspend fun mute(roomId: RoomId)
suspend fun unmute(roomId: RoomId)
fun isMuted(roomId: RoomId): Flow<Boolean>
} }
internal fun joinedMembersRequest(roomId: RoomId) = httpRequest<JoinedMembersResponse>( internal fun joinedMembersRequest(roomId: RoomId) = httpRequest<JoinedMembersResponse>(

View File

@ -5,7 +5,7 @@ import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.common.SyncToken import app.dapk.st.matrix.common.SyncToken
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface RoomStore { interface RoomStore : MuteableStore {
suspend fun persist(roomId: RoomId, events: List<RoomEvent>) suspend fun persist(roomId: RoomId, events: List<RoomEvent>)
suspend fun remove(rooms: List<RoomId>) suspend fun remove(rooms: List<RoomId>)
@ -16,11 +16,19 @@ interface RoomStore {
suspend fun markRead(roomId: RoomId) suspend fun markRead(roomId: RoomId)
fun observeUnread(): Flow<Map<RoomOverview, List<RoomEvent>>> fun observeUnread(): Flow<Map<RoomOverview, List<RoomEvent>>>
fun observeUnreadCountById(): Flow<Map<RoomId, Int>> fun observeUnreadCountById(): Flow<Map<RoomId, Int>>
fun observeNotMutedUnread(): Flow<Map<RoomOverview, List<RoomEvent>>>
fun observeEvent(eventId: EventId): Flow<EventId> fun observeEvent(eventId: EventId): Flow<EventId>
suspend fun findEvent(eventId: EventId): RoomEvent? 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<Set<RoomId>>
}
interface FilterStore { interface FilterStore {
suspend fun store(key: String, filterId: String) suspend fun store(key: String, filterId: String)

View File

@ -26,15 +26,18 @@ internal class UnreadEventsProcessor(
isInitialSync -> { isInitialSync -> {
// let's assume everything is read // let's assume everything is read
} }
previousState?.readMarker != overview.readMarker -> { previousState?.readMarker != overview.readMarker -> {
// assume the user has viewed the room // assume the user has viewed the room
logger.matrixLog(MatrixLogTag.SYNC, "marking room read due to new read marker") logger.matrixLog(MatrixLogTag.SYNC, "marking room read due to new read marker")
roomStore.markRead(overview.roomId) roomStore.markRead(overview.roomId)
} }
areWeViewingRoom -> { areWeViewingRoom -> {
logger.matrixLog(MatrixLogTag.SYNC, "marking room read") logger.matrixLog(MatrixLogTag.SYNC, "marking room read")
roomStore.markRead(overview.roomId) roomStore.markRead(overview.roomId)
} }
newEvents.isNotEmpty() -> { newEvents.isNotEmpty() -> {
logger.matrixLog(MatrixLogTag.SYNC, "insert new unread events") logger.matrixLog(MatrixLogTag.SYNC, "insert new unread events")

View File

@ -34,4 +34,8 @@ class FakeRoomStore : RoomStore by mockk() {
every { observeUnread() } returns unreadEvents every { observeUnread() } returns unreadEvents
} }
fun givenNotMutedUnreadEvents(unreadEvents: Flow<Map<RoomOverview, List<RoomEvent>>>) {
every { observeNotMutedUnread() } returns unreadEvents
}
} }

View File

@ -23,6 +23,7 @@ import app.dapk.st.matrix.message.internal.ImageContentReader
import app.dapk.st.matrix.push.installPushService import app.dapk.st.matrix.push.installPushService
import app.dapk.st.matrix.room.RoomMessenger import app.dapk.st.matrix.room.RoomMessenger
import app.dapk.st.matrix.room.installRoomService 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.room.roomService
import app.dapk.st.matrix.sync.* import app.dapk.st.matrix.sync.*
import app.dapk.st.matrix.sync.internal.request.ApiToDeviceEvent 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.OlmPersistenceWrapper
import app.dapk.st.olm.OlmWrapper import app.dapk.st.olm.OlmWrapper
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.amshove.kluent.fail import org.amshove.kluent.fail
import test.impl.InMemoryDatabase 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( 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<Boolean> = roomStore.observeMuted().map { it.contains(roomId) }.distinctUntilChanged()
}
fun MatrixClient.proxyDeviceService() = this.deviceService() as ProxyDeviceService fun MatrixClient.proxyDeviceService() = this.deviceService() as ProxyDeviceService

View File

@ -1,7 +1,6 @@
package test.impl package test.impl
import app.dapk.st.core.Preferences import app.dapk.st.core.Preferences
import test.unit
class InMemoryPreferences : Preferences { class InMemoryPreferences : Preferences {
@ -12,7 +11,13 @@ class InMemoryPreferences : Preferences {
} }
override suspend fun readString(key: String): String? = prefs[key] 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()
}
} }