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
suspend fun muteRoom(roomId: RoomId)
suspend fun unmuteRoom(roomId: RoomId)
}
interface TaskRunner {

View File

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

View File

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

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

@ -17,4 +17,5 @@ suspend fun CachedPreferences.readBoolean(key: String, defaultValue: Boolean) =
.toBooleanStrict()
suspend fun Preferences.readBoolean(key: String) = this.readString(key)?.toBooleanStrict()
suspend fun Preferences.store(key: String, value: Boolean) = this.store(key, value.toString())
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 kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.launch
class StateViewModel<S, E>(
reducerFactory: ReducerFactory<S>,
@ -32,7 +31,7 @@ class StateViewModel<S, E>(
}
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 reducer = reducerFactory.create(scope)
override suspend fun dispatch(action: Action) {
scope.coroutineScope.launch {
override fun dispatch(action: Action) {
coroutineScope.launch {
state = reducer.reduce(action).also { nextState ->
if (nextState != state) {
subscribers.forEach { it.invoke(nextState) }
@ -35,7 +35,7 @@ interface ReducerFactory<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> {
@ -45,7 +45,7 @@ private fun <S> createScope(coroutineScope: CoroutineScope, store: Store<S>) = o
}
interface Store<S> {
suspend fun dispatch(action: Action)
fun dispatch(action: Action)
fun getState(): S
fun subscribe(subscriber: (S) -> Unit)
}
@ -82,14 +82,18 @@ fun <S> createReducer(
actionHandlers.fold(acc) { acc, handler ->
when (handler) {
is ActionHandler.Async -> {
handler.handler.invoke(scope, action)
scope.coroutineScope.launch {
handler.handler.invoke(scope, action)
}
acc
}
is ActionHandler.Sync -> handler.handler.invoke(action, acc)
is ActionHandler.Delegate -> when (val next = handler.handler.invoke(scope, action)) {
is ActionHandler.Async -> {
next.handler.invoke(scope, action)
scope.coroutineScope.launch {
next.handler.invoke(scope, action)
}
acc
}

View File

@ -53,7 +53,7 @@ class ReducerTestScope<S, E>(
}
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
}

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

View File

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

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.st.core.CoroutineDispatchers
import app.dapk.st.core.withIoContext
import app.dapk.st.domain.room.MutedStorePersistence
import app.dapk.st.matrix.common.EventId
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.sync.RoomEvent
import app.dapk.st.matrix.sync.RoomOverview
import app.dapk.st.matrix.sync.RoomState
import app.dapk.st.matrix.sync.RoomStore
import app.dapk.st.matrix.sync.*
import com.squareup.sqldelight.Query
import com.squareup.sqldelight.runtime.coroutines.asFlow
import com.squareup.sqldelight.runtime.coroutines.mapToList
import com.squareup.sqldelight.runtime.coroutines.mapToOneNotNull
@ -25,7 +24,8 @@ internal class RoomPersistence(
private val database: DapkDb,
private val overviewPersistence: OverviewPersistence,
private val coroutineDispatchers: CoroutineDispatchers,
) : RoomStore {
private val muteableStore: MutedStorePersistence,
) : RoomStore, MuteableStore by muteableStore {
override suspend fun persist(roomId: RoomId, events: List<RoomEvent>) {
coroutineDispatchers.withIoContext {
@ -57,10 +57,8 @@ internal class RoomPersistence(
}.distinctUntilChanged()
return database.roomEventQueries.selectRoom(roomId.value)
.asFlow()
.mapToList()
.distinctFlowList()
.map { it.map { json.decodeFromString(RoomEvent.serializer(), it) } }
.distinctUntilChanged()
.combine(overviewFlow) { events, overview ->
RoomState(overview, events)
}
@ -92,9 +90,7 @@ internal class RoomPersistence(
override fun observeUnread(): Flow<Map<RoomOverview, List<RoomEvent>>> {
return database.roomEventQueries.selectAllUnread()
.asFlow()
.mapToList()
.distinctUntilChanged()
.distinctFlowList()
.map {
it.groupBy { RoomId(it.room_id) }
.mapKeys { overviewPersistence.retrieve(it.key)!! }
@ -116,6 +112,22 @@ internal class RoomPersistence(
}
}
override fun observeNotMutedUnread(): Flow<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) {
coroutineDispatchers.withIoContext {
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
LIMIT 100;
selectNotMutedUnread:
SELECT dbRoomEvent.blob, dbRoomEvent.room_id
FROM dbUnreadEvent
INNER JOIN dbRoomEvent ON dbUnreadEvent.event_id = dbRoomEvent.event_id
LEFT OUTER JOIN dbMutedRoom
ON dbUnreadEvent.room_id = dbMutedRoom.room_id
WHERE dbMutedRoom.room_id IS NULL
ORDER BY dbRoomEvent.timestamp_utc DESC
LIMIT 100;
remove:
DELETE FROM dbRoomEvent
WHERE room_id = ?;

View File

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

View File

@ -2,6 +2,7 @@ package app.dapk.st.directory
import android.content.Intent
import android.net.Uri
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
@ -10,6 +11,13 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Speaker
import androidx.compose.material.icons.filled.VolumeMute
import androidx.compose.material.icons.filled.VolumeOff
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.outlined.SpeakerNotesOff
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.*
@ -198,36 +206,24 @@ private fun DirectoryItem(room: DirectoryItem, onClick: (RoomId) -> Unit, clock:
)
}
if (hasUnread) {
if (hasUnread || room.isMuted) {
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Box(modifier = Modifier.weight(1f)) {
body(overview, secondaryText, room.typing)
}
Spacer(modifier = Modifier.width(6.dp))
Box(Modifier.align(Alignment.CenterVertically)) {
Box(
Modifier
.align(Alignment.Center)
.background(color = MaterialTheme.colorScheme.primary, shape = CircleShape)
.size(22.dp),
contentAlignment = Alignment.Center
) {
val unreadTextSize = when (room.unreadCount.value > 99) {
true -> 9.sp
false -> 10.sp
}
val unreadLabelContent = when {
room.unreadCount.value > 99 -> "99+"
else -> room.unreadCount.value.toString()
}
Text(
fontSize = unreadTextSize,
fontWeight = FontWeight.Medium,
text = unreadLabelContent,
color = MaterialTheme.colorScheme.onPrimary
)
if (hasUnread) {
Spacer(modifier = Modifier.width(6.dp))
Box(Modifier.align(Alignment.CenterVertically)) {
UnreadCircle(room)
}
}
if (room.isMuted) {
Spacer(modifier = Modifier.width(6.dp))
Icon(
imageVector = Icons.Filled.VolumeOff,
contentDescription = "",
)
}
}
} else {
body(overview, secondaryText, room.typing)
@ -237,6 +233,32 @@ private fun DirectoryItem(room: DirectoryItem, onClick: (RoomId) -> Unit, clock:
}
}
@Composable
private fun BoxScope.UnreadCircle(room: DirectoryItem) {
Box(
Modifier.Companion
.align(Alignment.Center)
.background(color = MaterialTheme.colorScheme.primary, shape = CircleShape)
.size(22.dp),
contentAlignment = Alignment.Center
) {
val unreadTextSize = when (room.unreadCount.value > 99) {
true -> 9.sp
false -> 10.sp
}
val unreadLabelContent = when {
room.unreadCount.value > 99 -> "99+"
else -> room.unreadCount.value.toString()
}
Text(
fontSize = unreadTextSize,
fontWeight = FontWeight.Medium,
text = unreadLabelContent,
color = MaterialTheme.colorScheme.onPrimary
)
}
}
@Composable
private fun body(overview: RoomOverview, secondaryText: Color, typing: Typing?) {
val bodySize = 14.sp

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

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

View File

@ -87,9 +87,20 @@ internal fun MessengerScreen(
Column {
Toolbar(onNavigate = { navigator.navigate.upToHome() }, roomTitle, actions = {
// OverflowMenu {
// DropdownMenuItem(text = { Text("Settings", color = MaterialTheme.colorScheme.onSecondaryContainer) }, onClick = {})
// }
state.roomState.takeIfContent()?.let {
OverflowMenu {
when (it.isMuted) {
true -> DropdownMenuItem(text = { Text("Unmute notifications", color = MaterialTheme.colorScheme.onSecondaryContainer) }, onClick = {
viewModel.dispatch(ScreenAction.Notifications.Unmute)
})
false -> DropdownMenuItem(text = { Text("Mute notifications", color = MaterialTheme.colorScheme.onSecondaryContainer) }, onClick = {
viewModel.dispatch(ScreenAction.Notifications.Mute)
})
}
}
}
})
when (state.composerState) {

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

@ -42,9 +42,10 @@ internal fun messengerReducer(
val state = getState()
when (action) {
is ComponentLifecycle.Visible -> {
jobBag.add("messages", chatEngine.messages(state.roomId, disableReadReceipts = messageOptionsStore.isReadReceiptsDisabled())
.onEach { dispatch(MessagesStateChange.Content(it)) }
.launchIn(coroutineScope)
jobBag.replace(
"messages", chatEngine.messages(state.roomId, disableReadReceipts = messageOptionsStore.isReadReceiptsDisabled())
.onEach { dispatch(MessagesStateChange.Content(it)) }
.launchIn(coroutineScope)
)
}
@ -134,6 +135,30 @@ internal fun messengerReducer(
}
}
},
change(MessagesStateChange.MuteContent::class) { action, state ->
when (val roomState = state.roomState) {
is Lce.Content -> state.copy(roomState = roomState.copy(value = roomState.value.copy(isMuted = action.isMuted)))
is Lce.Error -> state
is Lce.Loading -> state
}
},
async(ScreenAction.Notifications::class) { action ->
when (action) {
ScreenAction.Notifications.Mute -> chatEngine.muteRoom(roomId)
ScreenAction.Notifications.Unmute -> chatEngine.unmuteRoom(roomId)
}
dispatch(
MessagesStateChange.MuteContent(
isMuted = when (action) {
ScreenAction.Notifications.Mute -> true
ScreenAction.Notifications.Unmute -> false
}
)
)
},
)
}
@ -158,7 +183,6 @@ private fun RoomEvent.toSendMessageReply() = SendMessage.TextMessage.Reply(
timestampUtc = this.utcTimestamp,
)
private fun initialComposerState(initialAttachments: List<MessageAttachment>?) = initialAttachments
?.takeIf { it.isNotEmpty() }
?.let { ComposerState.Attachments(it, null) }

View File

@ -14,7 +14,7 @@ data class MessengerScreenState(
val roomId: RoomId,
val roomState: Lce<MessengerPageState>,
val composerState: ComposerState,
val viewerState: ViewerState?
val viewerState: 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 const val A_MESSAGE_CONTENT = "message content"
private val AN_EVENT_ID = anEventId("state event")
private const val ROOM_IS_MUTED = true
private val A_SELF_ID = aUserId("self")
private val A_MESSENGER_PAGE_STATE = aMessengerStateWithEvent(AN_EVENT_ID, A_SELF_ID)
private val A_MESSAGE_ATTACHMENT = MessageAttachment(AndroidUri("a-uri"), MimeType.Image)
@ -101,7 +102,7 @@ class MessengerReducerTest {
@Test
fun `given messages emits state, when Visible, then dispatches content`() = runReducerTest {
fakeJobBag.instance.expect { it.add("messages", any()) }
fakeJobBag.instance.expect { it.replace("messages", any()) }
fakeMessageOptionsStore.givenReadReceiptsDisabled().returns(READ_RECEIPTS_ARE_DISABLED)
val state = aMessengerStateWithEvent(AN_EVENT_ID, A_SELF_ID)
fakeChatEngine.givenMessages(A_ROOM_ID, READ_RECEIPTS_ARE_DISABLED).returns(flowOf(state))
@ -359,4 +360,4 @@ class FakeDeviceMeta {
val instance = mockk<DeviceMeta>()
fun givenApiVersion() = every { instance.apiVersion }.delegateReturn()
}
}

View File

@ -22,13 +22,15 @@ internal class DirectoryUseCase(
overviewDatasource(),
messageService.localEchos(),
roomStore.observeUnreadCountById(),
syncService.events()
) { overviewState, localEchos, unread, events ->
syncService.events(),
roomStore.observeMuted(),
) { overviewState, localEchos, unread, events, muted ->
overviewState.mergeWithLocalEchos(localEchos, userId).map { roomOverview ->
DirectoryItem(
overview = roomOverview,
unreadCount = UnreadCount(unread[roomOverview.roomId] ?: 0),
typing = events.filterIsInstance<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.BuildMeta
import app.dapk.st.core.CoroutineDispatchers
import app.dapk.st.core.SingletonFlows
import app.dapk.st.core.extensions.ErrorTracker
import app.dapk.st.matrix.MatrixClient
import app.dapk.st.matrix.MatrixTaskRunner
import app.dapk.st.matrix.auth.DeviceDisplayNameGenerator
import app.dapk.st.matrix.auth.authService
import app.dapk.st.matrix.auth.installAuthService
import app.dapk.st.matrix.common.*
import app.dapk.st.matrix.crypto.*
import app.dapk.st.matrix.common.CredentialsStore
import app.dapk.st.matrix.common.MatrixLogger
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.crypto.MatrixMediaDecrypter
import app.dapk.st.matrix.crypto.cryptoService
import app.dapk.st.matrix.device.KnownDeviceStore
import app.dapk.st.matrix.device.deviceService
import app.dapk.st.matrix.device.installEncryptionService
import app.dapk.st.matrix.http.ktor.KtorMatrixHttpClientFactory
import app.dapk.st.matrix.message.*
import app.dapk.st.matrix.message.BackgroundScheduler
import app.dapk.st.matrix.message.LocalEchoStore
import app.dapk.st.matrix.message.internal.ImageContentReader
import app.dapk.st.matrix.push.installPushService
import app.dapk.st.matrix.message.messageService
import app.dapk.st.matrix.push.pushService
import app.dapk.st.matrix.room.*
import app.dapk.st.matrix.room.MemberStore
import app.dapk.st.matrix.room.ProfileStore
import app.dapk.st.matrix.room.profileService
import app.dapk.st.matrix.room.roomService
import app.dapk.st.matrix.sync.*
import app.dapk.st.matrix.sync.internal.request.ApiToDeviceEvent
import app.dapk.st.matrix.sync.internal.room.MessageDecrypter
import app.dapk.st.olm.DeviceKeyFactory
import app.dapk.st.olm.OlmStore
import app.dapk.st.olm.OlmWrapper
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
@ -114,6 +112,10 @@ class MatrixEngine internal constructor(
override fun pushHandler() = matrixPushHandler.value
override suspend fun muteRoom(roomId: RoomId) = matrix.value.roomService().muteRoom(roomId)
override suspend fun unmuteRoom(roomId: RoomId) = matrix.value.roomService().unmuteRoom(roomId)
override suspend fun runTask(task: ChatEngineTask): TaskRunner.TaskResult {
return when (val result = matrix.value.run(MatrixTaskRunner.MatrixTask(task.type, task.jsonPayload))) {
is MatrixTaskRunner.TaskResult.Failure -> TaskRunner.TaskResult.Failure(result.canRetry)
@ -209,222 +211,4 @@ class MatrixEngine internal constructor(
}
object MatrixFactory {
fun createMatrix(
base64: Base64,
buildMeta: BuildMeta,
logger: MatrixLogger,
nameGenerator: DeviceDisplayNameGenerator,
coroutineDispatchers: CoroutineDispatchers,
errorTracker: ErrorTracker,
imageContentReader: ImageContentReader,
backgroundScheduler: BackgroundScheduler,
memberStore: MemberStore,
roomStore: RoomStore,
profileStore: ProfileStore,
syncStore: SyncStore,
overviewStore: OverviewStore,
filterStore: FilterStore,
localEchoStore: LocalEchoStore,
credentialsStore: CredentialsStore,
knownDeviceStore: KnownDeviceStore,
olmStore: OlmStore,
) = MatrixClient(
KtorMatrixHttpClientFactory(
credentialsStore,
includeLogging = buildMeta.isDebug,
),
logger
).also {
it.install {
installAuthService(credentialsStore, nameGenerator)
installEncryptionService(knownDeviceStore)
val singletonFlows = SingletonFlows(coroutineDispatchers)
val olm = OlmWrapper(
olmStore = olmStore,
singletonFlows = singletonFlows,
jsonCanonicalizer = JsonCanonicalizer(),
deviceKeyFactory = DeviceKeyFactory(JsonCanonicalizer()),
errorTracker = errorTracker,
logger = logger,
clock = Clock.systemUTC(),
coroutineDispatchers = coroutineDispatchers,
)
installCryptoService(
credentialsStore,
olm,
roomMembersProvider = { services ->
RoomMembersProvider {
services.roomService().joinedMembers(it).map { it.userId }
}
},
base64 = base64,
coroutineDispatchers = coroutineDispatchers,
)
installMessageService(
localEchoStore,
backgroundScheduler,
imageContentReader,
messageEncrypter = {
val cryptoService = it.cryptoService()
MessageEncrypter { message ->
val result = cryptoService.encrypt(
roomId = message.roomId,
credentials = credentialsStore.credentials()!!,
messageJson = message.contents,
)
MessageEncrypter.EncryptedMessagePayload(
result.algorithmName,
result.senderKey,
result.cipherText,
result.sessionId,
result.deviceId,
)
}
},
mediaEncrypter = {
val cryptoService = it.cryptoService()
MediaEncrypter { input ->
val result = cryptoService.encrypt(input)
MediaEncrypter.Result(
uri = result.uri,
contentLength = result.contentLength,
algorithm = result.algorithm,
ext = result.ext,
keyOperations = result.keyOperations,
kty = result.kty,
k = result.k,
iv = result.iv,
hashes = result.hashes,
v = result.v,
)
}
},
)
installRoomService(
memberStore,
roomMessenger = {
val messageService = it.messageService()
object : RoomMessenger {
override suspend fun enableEncryption(roomId: RoomId) {
messageService.sendEventMessage(
roomId, MessageService.EventMessage.Encryption(
algorithm = AlgorithmName("m.megolm.v1.aes-sha2")
)
)
}
}
},
roomInviteRemover = {
overviewStore.removeInvites(listOf(it))
}
)
installProfileService(profileStore, singletonFlows, credentialsStore)
installSyncService(
credentialsStore,
overviewStore,
roomStore,
syncStore,
filterStore,
deviceNotifier = { services ->
val encryption = services.deviceService()
val crypto = services.cryptoService()
DeviceNotifier { userIds, syncToken ->
encryption.updateStaleDevices(userIds)
crypto.updateOlmSession(userIds, syncToken)
}
},
messageDecrypter = { serviceProvider ->
val cryptoService = serviceProvider.cryptoService()
MessageDecrypter {
cryptoService.decrypt(it)
}
},
keySharer = { serviceProvider ->
val cryptoService = serviceProvider.cryptoService()
KeySharer { sharedRoomKeys ->
cryptoService.importRoomKeys(sharedRoomKeys)
}
},
verificationHandler = { services ->
val cryptoService = services.cryptoService()
VerificationHandler { apiEvent ->
logger.matrixLog(MatrixLogTag.VERIFICATION, "got a verification request $it")
cryptoService.onVerificationEvent(
when (apiEvent) {
is ApiToDeviceEvent.VerificationRequest -> Verification.Event.Requested(
apiEvent.sender,
apiEvent.content.fromDevice,
apiEvent.content.transactionId,
apiEvent.content.methods,
apiEvent.content.timestampPosix,
)
is ApiToDeviceEvent.VerificationReady -> Verification.Event.Ready(
apiEvent.sender,
apiEvent.content.fromDevice,
apiEvent.content.transactionId,
apiEvent.content.methods,
)
is ApiToDeviceEvent.VerificationStart -> Verification.Event.Started(
apiEvent.sender,
apiEvent.content.fromDevice,
apiEvent.content.method,
apiEvent.content.protocols,
apiEvent.content.hashes,
apiEvent.content.codes,
apiEvent.content.short,
apiEvent.content.transactionId,
)
is ApiToDeviceEvent.VerificationCancel -> TODO()
is ApiToDeviceEvent.VerificationAccept -> TODO()
is ApiToDeviceEvent.VerificationKey -> Verification.Event.Key(
apiEvent.sender,
apiEvent.content.transactionId,
apiEvent.content.key
)
is ApiToDeviceEvent.VerificationMac -> Verification.Event.Mac(
apiEvent.sender,
apiEvent.content.transactionId,
apiEvent.content.keys,
apiEvent.content.mac,
)
}
)
}
},
oneTimeKeyProducer = { services ->
val cryptoService = services.cryptoService()
MaybeCreateMoreKeys {
cryptoService.maybeCreateMoreKeys(it)
}
},
roomMembersService = { services ->
val roomService = services.roomService()
object : RoomMembersService {
override suspend fun find(roomId: RoomId, userIds: List<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)
private 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 {
override fun invoke(): Flow<UnreadNotifications> {
return roomStore.observeUnread()
return roomStore.observeNotMutedUnread()
.mapWithDiff()
.avoidShowingPreviousNotificationsOnLaunch()
.onlyRenderableChanges()

View File

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

View File

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

View File

@ -22,6 +22,7 @@ import org.junit.Test
import test.FlowTestObserver
import test.delegateReturn
private const val IS_ROOM_MUTED = false
private val A_ROOM_ID = aRoomId()
private val AN_USER_ID = aUserId()
private val A_ROOM_STATE = aMatrixRoomState()
@ -63,6 +64,7 @@ class TimelineUseCaseTest {
fakeMergeWithLocalEchosUseCase.givenMerging(A_ROOM_STATE, A_ROOM_MEMBER, A_LOCAL_ECHOS_LIST).returns(A_MERGED_ROOM_STATE.engine())
timelineUseCase.invoke(A_ROOM_ID, AN_USER_ID)
.test(this)
.assertValues(
@ -103,6 +105,7 @@ class TimelineUseCaseTest {
fakeSyncService.givenRoom(A_ROOM_ID).returns(flowOf(roomState))
fakeMessageService.givenEchos(A_ROOM_ID).returns(flowOf(echos))
fakeSyncService.givenEvents(A_ROOM_ID).returns(flowOf(events))
fakeRoomService.givenMuted(A_ROOM_ID).returns(flowOf(IS_ROOM_MUTED))
}
}
@ -129,10 +132,12 @@ class FakeMessageService : MessageService by mockk() {
class FakeRoomService : RoomService by mockk() {
fun givenFindMember(roomId: RoomId, userId: UserId) = coEvery { findMember(roomId, userId) }.delegateReturn()
fun givenMuted(roomId: RoomId) = every { observeIsMuted(roomId) }.delegateReturn()
}
fun aMessengerState(
self: UserId = aUserId(),
roomState: app.dapk.st.engine.RoomState,
typing: Typing? = null
) = MessengerPageState(self, roomState, typing)
typing: Typing? = null,
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.RoomMember
import app.dapk.st.matrix.common.UserId
import app.dapk.st.matrix.room.internal.DefaultRoomService
import app.dapk.st.matrix.room.internal.RoomInviteRemover
import app.dapk.st.matrix.room.internal.RoomMembers
import app.dapk.st.matrix.room.internal.RoomMembersCache
import app.dapk.st.matrix.room.internal.*
import kotlinx.coroutines.flow.Flow
private val SERVICE_KEY = RoomService::class
@ -27,6 +25,10 @@ interface RoomService : MatrixService {
suspend fun joinRoom(roomId: RoomId)
suspend fun rejectJoinRoom(roomId: RoomId)
suspend fun muteRoom(roomId: RoomId)
suspend fun unmuteRoom(roomId: RoomId)
fun observeIsMuted(roomId: RoomId): Flow<Boolean>
data class JoinedMember(
val userId: UserId,
val displayName: String?,
@ -39,6 +41,7 @@ fun MatrixServiceInstaller.installRoomService(
memberStore: MemberStore,
roomMessenger: ServiceDepFactory<RoomMessenger>,
roomInviteRemover: RoomInviteRemover,
singleRoomStore: SingleRoomStore,
): InstallExtender<RoomService> {
return this.install { (httpClient, _, services, logger) ->
SERVICE_KEY to DefaultRoomService(
@ -46,7 +49,8 @@ fun MatrixServiceInstaller.installRoomService(
logger,
RoomMembers(memberStore, RoomMembersCache()),
roomMessenger.create(services),
roomInviteRemover
roomInviteRemover,
singleRoomStore,
)
}
}

View File

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

View File

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

View File

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

View File

@ -34,4 +34,8 @@ class FakeRoomStore : RoomStore by mockk() {
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.room.RoomMessenger
import app.dapk.st.matrix.room.installRoomService
import app.dapk.st.matrix.room.internal.SingleRoomStore
import app.dapk.st.matrix.room.roomService
import app.dapk.st.matrix.sync.*
import app.dapk.st.matrix.sync.internal.request.ApiToDeviceEvent
@ -31,6 +32,9 @@ import app.dapk.st.olm.DeviceKeyFactory
import app.dapk.st.olm.OlmPersistenceWrapper
import app.dapk.st.olm.OlmWrapper
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.serialization.json.Json
import org.amshove.kluent.fail
import test.impl.InMemoryDatabase
@ -178,7 +182,8 @@ class TestMatrix(
}
}
},
roomInviteRemover = { storeModule.overviewStore().removeInvites(listOf(it)) }
roomInviteRemover = { storeModule.overviewStore().removeInvites(listOf(it)) },
singleRoomStore = singleRoomStoreAdapter(storeModule.roomStore())
)
installSyncService(
@ -378,4 +383,10 @@ class ProxyDeviceService(private val deviceService: DeviceService) : DeviceServi
}
private fun singleRoomStoreAdapter(roomStore: RoomStore) = object : SingleRoomStore {
override suspend fun mute(roomId: RoomId) = roomStore.mute(roomId)
override suspend fun unmute(roomId: RoomId) = roomStore.unmute(roomId)
override fun isMuted(roomId: RoomId): Flow<Boolean> = roomStore.observeMuted().map { it.contains(roomId) }.distinctUntilChanged()
}
fun MatrixClient.proxyDeviceService() = this.deviceService() as ProxyDeviceService

View File

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