Merge pull request #243 from ouchadam/feature/mute-room-notifications
Mute room notifications
This commit is contained in:
commit
1237cd21a0
|
@ -36,6 +36,8 @@ interface ChatEngine : TaskRunner {
|
|||
|
||||
fun pushHandler(): PushHandler
|
||||
|
||||
suspend fun muteRoom(roomId: RoomId)
|
||||
suspend fun unmuteRoom(roomId: RoomId)
|
||||
}
|
||||
|
||||
interface TaskRunner {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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())
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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() }
|
||||
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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;
|
|
@ -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 = ?;
|
||||
|
|
|
@ -16,7 +16,6 @@ FROM dbRoomMember
|
|||
WHERE room_id = ?
|
||||
LIMIT ?;
|
||||
|
||||
|
||||
insert:
|
||||
INSERT OR REPLACE INTO dbRoomMember(user_id, room_id, blob)
|
||||
VALUES (?, ?, ?);
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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) }
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>(
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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")
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue