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
|
fun pushHandler(): PushHandler
|
||||||
|
|
||||||
|
suspend fun muteRoom(roomId: RoomId)
|
||||||
|
suspend fun unmuteRoom(roomId: RoomId)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TaskRunner {
|
interface TaskRunner {
|
||||||
|
|
|
@ -13,7 +13,8 @@ typealias InviteState = List<RoomInvite>
|
||||||
data class DirectoryItem(
|
data class DirectoryItem(
|
||||||
val overview: RoomOverview,
|
val overview: RoomOverview,
|
||||||
val unreadCount: UnreadCount,
|
val unreadCount: UnreadCount,
|
||||||
val typing: Typing?
|
val typing: Typing?,
|
||||||
|
val isMuted: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class RoomOverview(
|
data class RoomOverview(
|
||||||
|
@ -87,7 +88,8 @@ sealed interface ImportResult {
|
||||||
data class MessengerPageState(
|
data class MessengerPageState(
|
||||||
val self: UserId,
|
val self: UserId,
|
||||||
val roomState: RoomState,
|
val roomState: RoomState,
|
||||||
val typing: Typing?
|
val typing: Typing?,
|
||||||
|
val isMuted: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class RoomState(
|
data class RoomState(
|
||||||
|
|
|
@ -6,8 +6,9 @@ import app.dapk.st.matrix.common.*
|
||||||
fun aMessengerState(
|
fun aMessengerState(
|
||||||
self: UserId = aUserId(),
|
self: UserId = aUserId(),
|
||||||
roomState: RoomState,
|
roomState: RoomState,
|
||||||
typing: Typing? = null
|
typing: Typing? = null,
|
||||||
) = MessengerPageState(self, roomState, typing)
|
isMuted: Boolean = false,
|
||||||
|
) = MessengerPageState(self, roomState, typing, isMuted)
|
||||||
|
|
||||||
fun aRoomOverview(
|
fun aRoomOverview(
|
||||||
roomId: RoomId = aRoomId(),
|
roomId: RoomId = aRoomId(),
|
||||||
|
|
|
@ -6,7 +6,8 @@ class JobBag {
|
||||||
|
|
||||||
private val jobs = mutableMapOf<String, Job>()
|
private val jobs = mutableMapOf<String, Job>()
|
||||||
|
|
||||||
fun add(key: String, job: Job) {
|
fun replace(key: String, job: Job) {
|
||||||
|
jobs[key]?.cancel()
|
||||||
jobs[key] = job
|
jobs[key] = job
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -17,4 +17,5 @@ suspend fun CachedPreferences.readBoolean(key: String, defaultValue: Boolean) =
|
||||||
.toBooleanStrict()
|
.toBooleanStrict()
|
||||||
|
|
||||||
suspend fun Preferences.readBoolean(key: String) = this.readString(key)?.toBooleanStrict()
|
suspend fun Preferences.readBoolean(key: String) = this.readString(key)?.toBooleanStrict()
|
||||||
suspend fun Preferences.store(key: String, value: Boolean) = this.store(key, value.toString())
|
suspend fun Preferences.store(key: String, value: Boolean) = this.store(key, value.toString())
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,6 @@ import app.dapk.state.Store
|
||||||
import app.dapk.state.createStore
|
import app.dapk.state.createStore
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
class StateViewModel<S, E>(
|
class StateViewModel<S, E>(
|
||||||
reducerFactory: ReducerFactory<S>,
|
reducerFactory: ReducerFactory<S>,
|
||||||
|
@ -32,7 +31,7 @@ class StateViewModel<S, E>(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun dispatch(action: Action) {
|
override fun dispatch(action: Action) {
|
||||||
viewModelScope.launch { store.dispatch(action) }
|
store.dispatch(action)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,8 +11,8 @@ fun <S> createStore(reducerFactory: ReducerFactory<S>, coroutineScope: Coroutine
|
||||||
private val scope = createScope(coroutineScope, this)
|
private val scope = createScope(coroutineScope, this)
|
||||||
private val reducer = reducerFactory.create(scope)
|
private val reducer = reducerFactory.create(scope)
|
||||||
|
|
||||||
override suspend fun dispatch(action: Action) {
|
override fun dispatch(action: Action) {
|
||||||
scope.coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
state = reducer.reduce(action).also { nextState ->
|
state = reducer.reduce(action).also { nextState ->
|
||||||
if (nextState != state) {
|
if (nextState != state) {
|
||||||
subscribers.forEach { it.invoke(nextState) }
|
subscribers.forEach { it.invoke(nextState) }
|
||||||
|
@ -35,7 +35,7 @@ interface ReducerFactory<S> {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun interface Reducer<S> {
|
fun interface Reducer<S> {
|
||||||
suspend fun reduce(action: Action): S
|
fun reduce(action: Action): S
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun <S> createScope(coroutineScope: CoroutineScope, store: Store<S>) = object : ReducerScope<S> {
|
private fun <S> createScope(coroutineScope: CoroutineScope, store: Store<S>) = object : ReducerScope<S> {
|
||||||
|
@ -45,7 +45,7 @@ private fun <S> createScope(coroutineScope: CoroutineScope, store: Store<S>) = o
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Store<S> {
|
interface Store<S> {
|
||||||
suspend fun dispatch(action: Action)
|
fun dispatch(action: Action)
|
||||||
fun getState(): S
|
fun getState(): S
|
||||||
fun subscribe(subscriber: (S) -> Unit)
|
fun subscribe(subscriber: (S) -> Unit)
|
||||||
}
|
}
|
||||||
|
@ -82,14 +82,18 @@ fun <S> createReducer(
|
||||||
actionHandlers.fold(acc) { acc, handler ->
|
actionHandlers.fold(acc) { acc, handler ->
|
||||||
when (handler) {
|
when (handler) {
|
||||||
is ActionHandler.Async -> {
|
is ActionHandler.Async -> {
|
||||||
handler.handler.invoke(scope, action)
|
scope.coroutineScope.launch {
|
||||||
|
handler.handler.invoke(scope, action)
|
||||||
|
}
|
||||||
acc
|
acc
|
||||||
}
|
}
|
||||||
|
|
||||||
is ActionHandler.Sync -> handler.handler.invoke(action, acc)
|
is ActionHandler.Sync -> handler.handler.invoke(action, acc)
|
||||||
is ActionHandler.Delegate -> when (val next = handler.handler.invoke(scope, action)) {
|
is ActionHandler.Delegate -> when (val next = handler.handler.invoke(scope, action)) {
|
||||||
is ActionHandler.Async -> {
|
is ActionHandler.Async -> {
|
||||||
next.handler.invoke(scope, action)
|
scope.coroutineScope.launch {
|
||||||
|
next.handler.invoke(scope, action)
|
||||||
|
}
|
||||||
acc
|
acc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -53,7 +53,7 @@ class ReducerTestScope<S, E>(
|
||||||
}
|
}
|
||||||
private val reducer: Reducer<S> = reducerFactory.create(reducerScope)
|
private val reducer: Reducer<S> = reducerFactory.create(reducerScope)
|
||||||
|
|
||||||
override suspend fun reduce(action: Action) = reducer.reduce(action).also {
|
override fun reduce(action: Action) = reducer.reduce(action).also {
|
||||||
capturedResult = it
|
capturedResult = it
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -13,6 +13,7 @@ import app.dapk.st.domain.preference.CachingPreferences
|
||||||
import app.dapk.st.domain.preference.PropertyCache
|
import app.dapk.st.domain.preference.PropertyCache
|
||||||
import app.dapk.st.domain.profile.ProfilePersistence
|
import app.dapk.st.domain.profile.ProfilePersistence
|
||||||
import app.dapk.st.domain.push.PushTokenRegistrarPreferences
|
import app.dapk.st.domain.push.PushTokenRegistrarPreferences
|
||||||
|
import app.dapk.st.domain.room.MutedStorePersistence
|
||||||
import app.dapk.st.domain.sync.OverviewPersistence
|
import app.dapk.st.domain.sync.OverviewPersistence
|
||||||
import app.dapk.st.domain.sync.RoomPersistence
|
import app.dapk.st.domain.sync.RoomPersistence
|
||||||
import app.dapk.st.matrix.common.CredentialsStore
|
import app.dapk.st.matrix.common.CredentialsStore
|
||||||
|
@ -33,8 +34,18 @@ class StoreModule(
|
||||||
private val coroutineDispatchers: CoroutineDispatchers,
|
private val coroutineDispatchers: CoroutineDispatchers,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
private val muteableStore by unsafeLazy { MutedStorePersistence(database, coroutineDispatchers) }
|
||||||
|
|
||||||
fun overviewStore(): OverviewStore = OverviewPersistence(database, coroutineDispatchers)
|
fun overviewStore(): OverviewStore = OverviewPersistence(database, coroutineDispatchers)
|
||||||
fun roomStore(): RoomStore = RoomPersistence(database, OverviewPersistence(database, coroutineDispatchers), coroutineDispatchers)
|
fun roomStore(): RoomStore {
|
||||||
|
return RoomPersistence(
|
||||||
|
database = database,
|
||||||
|
overviewPersistence = OverviewPersistence(database, coroutineDispatchers),
|
||||||
|
coroutineDispatchers = coroutineDispatchers,
|
||||||
|
muteableStore = muteableStore,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun credentialsStore(): CredentialsStore = CredentialsPreferences(credentialPreferences)
|
fun credentialsStore(): CredentialsStore = CredentialsPreferences(credentialPreferences)
|
||||||
fun syncStore(): SyncStore = SyncTokenPreferences(preferences)
|
fun syncStore(): SyncStore = SyncTokenPreferences(preferences)
|
||||||
fun filterStore(): FilterStore = FilterPreferences(preferences)
|
fun filterStore(): FilterStore = FilterPreferences(preferences)
|
||||||
|
|
|
@ -4,6 +4,7 @@ import app.dapk.st.core.CachedPreferences
|
||||||
import app.dapk.st.core.Preferences
|
import app.dapk.st.core.Preferences
|
||||||
|
|
||||||
class CachingPreferences(private val cache: PropertyCache, private val preferences: Preferences) : CachedPreferences {
|
class CachingPreferences(private val cache: PropertyCache, private val preferences: Preferences) : CachedPreferences {
|
||||||
|
|
||||||
override suspend fun store(key: String, value: String) {
|
override suspend fun store(key: String, value: String) {
|
||||||
cache.setValue(key, value)
|
cache.setValue(key, value)
|
||||||
preferences.store(key, value)
|
preferences.store(key, value)
|
||||||
|
|
|
@ -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.db.model.RoomEventQueries
|
||||||
import app.dapk.st.core.CoroutineDispatchers
|
import app.dapk.st.core.CoroutineDispatchers
|
||||||
import app.dapk.st.core.withIoContext
|
import app.dapk.st.core.withIoContext
|
||||||
|
import app.dapk.st.domain.room.MutedStorePersistence
|
||||||
import app.dapk.st.matrix.common.EventId
|
import app.dapk.st.matrix.common.EventId
|
||||||
import app.dapk.st.matrix.common.RoomId
|
import app.dapk.st.matrix.common.RoomId
|
||||||
import app.dapk.st.matrix.sync.RoomEvent
|
import app.dapk.st.matrix.sync.*
|
||||||
import app.dapk.st.matrix.sync.RoomOverview
|
import com.squareup.sqldelight.Query
|
||||||
import app.dapk.st.matrix.sync.RoomState
|
|
||||||
import app.dapk.st.matrix.sync.RoomStore
|
|
||||||
import com.squareup.sqldelight.runtime.coroutines.asFlow
|
import com.squareup.sqldelight.runtime.coroutines.asFlow
|
||||||
import com.squareup.sqldelight.runtime.coroutines.mapToList
|
import com.squareup.sqldelight.runtime.coroutines.mapToList
|
||||||
import com.squareup.sqldelight.runtime.coroutines.mapToOneNotNull
|
import com.squareup.sqldelight.runtime.coroutines.mapToOneNotNull
|
||||||
|
@ -25,7 +24,8 @@ internal class RoomPersistence(
|
||||||
private val database: DapkDb,
|
private val database: DapkDb,
|
||||||
private val overviewPersistence: OverviewPersistence,
|
private val overviewPersistence: OverviewPersistence,
|
||||||
private val coroutineDispatchers: CoroutineDispatchers,
|
private val coroutineDispatchers: CoroutineDispatchers,
|
||||||
) : RoomStore {
|
private val muteableStore: MutedStorePersistence,
|
||||||
|
) : RoomStore, MuteableStore by muteableStore {
|
||||||
|
|
||||||
override suspend fun persist(roomId: RoomId, events: List<RoomEvent>) {
|
override suspend fun persist(roomId: RoomId, events: List<RoomEvent>) {
|
||||||
coroutineDispatchers.withIoContext {
|
coroutineDispatchers.withIoContext {
|
||||||
|
@ -57,10 +57,8 @@ internal class RoomPersistence(
|
||||||
}.distinctUntilChanged()
|
}.distinctUntilChanged()
|
||||||
|
|
||||||
return database.roomEventQueries.selectRoom(roomId.value)
|
return database.roomEventQueries.selectRoom(roomId.value)
|
||||||
.asFlow()
|
.distinctFlowList()
|
||||||
.mapToList()
|
|
||||||
.map { it.map { json.decodeFromString(RoomEvent.serializer(), it) } }
|
.map { it.map { json.decodeFromString(RoomEvent.serializer(), it) } }
|
||||||
.distinctUntilChanged()
|
|
||||||
.combine(overviewFlow) { events, overview ->
|
.combine(overviewFlow) { events, overview ->
|
||||||
RoomState(overview, events)
|
RoomState(overview, events)
|
||||||
}
|
}
|
||||||
|
@ -92,9 +90,7 @@ internal class RoomPersistence(
|
||||||
|
|
||||||
override fun observeUnread(): Flow<Map<RoomOverview, List<RoomEvent>>> {
|
override fun observeUnread(): Flow<Map<RoomOverview, List<RoomEvent>>> {
|
||||||
return database.roomEventQueries.selectAllUnread()
|
return database.roomEventQueries.selectAllUnread()
|
||||||
.asFlow()
|
.distinctFlowList()
|
||||||
.mapToList()
|
|
||||||
.distinctUntilChanged()
|
|
||||||
.map {
|
.map {
|
||||||
it.groupBy { RoomId(it.room_id) }
|
it.groupBy { RoomId(it.room_id) }
|
||||||
.mapKeys { overviewPersistence.retrieve(it.key)!! }
|
.mapKeys { overviewPersistence.retrieve(it.key)!! }
|
||||||
|
@ -116,6 +112,22 @@ internal class RoomPersistence(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun observeNotMutedUnread(): Flow<Map<RoomOverview, List<RoomEvent>>> {
|
||||||
|
return database.roomEventQueries.selectNotMutedUnread()
|
||||||
|
.distinctFlowList()
|
||||||
|
.map {
|
||||||
|
it.groupBy { RoomId(it.room_id) }
|
||||||
|
.mapKeys { overviewPersistence.retrieve(it.key)!! }
|
||||||
|
.mapValues {
|
||||||
|
it.value.map {
|
||||||
|
json.decodeFromString(RoomEvent.serializer(), it.blob)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T : Any> Query<T>.distinctFlowList() = this.asFlow().mapToList().distinctUntilChanged()
|
||||||
|
|
||||||
override suspend fun markRead(roomId: RoomId) {
|
override suspend fun markRead(roomId: RoomId) {
|
||||||
coroutineDispatchers.withIoContext {
|
coroutineDispatchers.withIoContext {
|
||||||
database.unreadEventQueries.removeRead(room_id = roomId.value)
|
database.unreadEventQueries.removeRead(room_id = roomId.value)
|
||||||
|
|
|
@ -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
|
ORDER BY dbRoomEvent.timestamp_utc DESC
|
||||||
LIMIT 100;
|
LIMIT 100;
|
||||||
|
|
||||||
|
selectNotMutedUnread:
|
||||||
|
SELECT dbRoomEvent.blob, dbRoomEvent.room_id
|
||||||
|
FROM dbUnreadEvent
|
||||||
|
INNER JOIN dbRoomEvent ON dbUnreadEvent.event_id = dbRoomEvent.event_id
|
||||||
|
LEFT OUTER JOIN dbMutedRoom
|
||||||
|
ON dbUnreadEvent.room_id = dbMutedRoom.room_id
|
||||||
|
WHERE dbMutedRoom.room_id IS NULL
|
||||||
|
ORDER BY dbRoomEvent.timestamp_utc DESC
|
||||||
|
LIMIT 100;
|
||||||
|
|
||||||
remove:
|
remove:
|
||||||
DELETE FROM dbRoomEvent
|
DELETE FROM dbRoomEvent
|
||||||
WHERE room_id = ?;
|
WHERE room_id = ?;
|
||||||
|
|
|
@ -16,7 +16,6 @@ FROM dbRoomMember
|
||||||
WHERE room_id = ?
|
WHERE room_id = ?
|
||||||
LIMIT ?;
|
LIMIT ?;
|
||||||
|
|
||||||
|
|
||||||
insert:
|
insert:
|
||||||
INSERT OR REPLACE INTO dbRoomMember(user_id, room_id, blob)
|
INSERT OR REPLACE INTO dbRoomMember(user_id, room_id, blob)
|
||||||
VALUES (?, ?, ?);
|
VALUES (?, ?, ?);
|
|
@ -2,6 +2,7 @@ package app.dapk.st.directory
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
@ -10,6 +11,13 @@ import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Speaker
|
||||||
|
import androidx.compose.material.icons.filled.VolumeMute
|
||||||
|
import androidx.compose.material.icons.filled.VolumeOff
|
||||||
|
import androidx.compose.material.icons.outlined.MoreVert
|
||||||
|
import androidx.compose.material.icons.outlined.SpeakerNotesOff
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
@ -198,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()) {
|
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
||||||
Box(modifier = Modifier.weight(1f)) {
|
Box(modifier = Modifier.weight(1f)) {
|
||||||
body(overview, secondaryText, room.typing)
|
body(overview, secondaryText, room.typing)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
if (hasUnread) {
|
||||||
Box(Modifier.align(Alignment.CenterVertically)) {
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
Box(
|
Box(Modifier.align(Alignment.CenterVertically)) {
|
||||||
Modifier
|
UnreadCircle(room)
|
||||||
.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 (room.isMuted) {
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.VolumeOff,
|
||||||
|
contentDescription = "",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
body(overview, secondaryText, room.typing)
|
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
|
@Composable
|
||||||
private fun body(overview: RoomOverview, secondaryText: Color, typing: Typing?) {
|
private fun body(overview: RoomOverview, secondaryText: Color, typing: Typing?) {
|
||||||
val bodySize = 14.sp
|
val bodySize = 14.sp
|
||||||
|
|
|
@ -21,7 +21,7 @@ internal fun directoryReducer(
|
||||||
multi(ComponentLifecycle::class) { action ->
|
multi(ComponentLifecycle::class) { action ->
|
||||||
when (action) {
|
when (action) {
|
||||||
ComponentLifecycle.OnVisible -> async { _ ->
|
ComponentLifecycle.OnVisible -> async { _ ->
|
||||||
jobBag.add(KEY_SYNCING_JOB, chatEngine.directory().onEach {
|
jobBag.replace(KEY_SYNCING_JOB, chatEngine.directory().onEach {
|
||||||
shortcutHandler.onDirectoryUpdate(it.map { it.overview })
|
shortcutHandler.onDirectoryUpdate(it.map { it.overview })
|
||||||
when (it.isEmpty()) {
|
when (it.isEmpty()) {
|
||||||
true -> dispatch(DirectoryStateChange.Empty)
|
true -> dispatch(DirectoryStateChange.Empty)
|
||||||
|
|
|
@ -2,7 +2,6 @@ package app.dapk.st.directory
|
||||||
|
|
||||||
import app.dapk.st.core.JobBag
|
import app.dapk.st.core.JobBag
|
||||||
import app.dapk.st.directory.state.*
|
import app.dapk.st.directory.state.*
|
||||||
import app.dapk.st.engine.DirectoryItem
|
|
||||||
import app.dapk.st.engine.UnreadCount
|
import app.dapk.st.engine.UnreadCount
|
||||||
import fake.FakeChatEngine
|
import fake.FakeChatEngine
|
||||||
import fixture.aRoomOverview
|
import fixture.aRoomOverview
|
||||||
|
@ -13,7 +12,7 @@ import test.expect
|
||||||
import test.testReducer
|
import test.testReducer
|
||||||
|
|
||||||
private val AN_OVERVIEW = aRoomOverview()
|
private val AN_OVERVIEW = aRoomOverview()
|
||||||
private val AN_OVERVIEW_STATE = DirectoryItem(AN_OVERVIEW, UnreadCount(1), null)
|
private val AN_OVERVIEW_STATE = app.dapk.st.engine.DirectoryItem(AN_OVERVIEW, UnreadCount(1), null, isMuted = false)
|
||||||
|
|
||||||
class DirectoryReducerTest {
|
class DirectoryReducerTest {
|
||||||
|
|
||||||
|
@ -38,7 +37,7 @@ class DirectoryReducerTest {
|
||||||
@Test
|
@Test
|
||||||
fun `given directory content, when Visible, then updates shortcuts and dispatches room state`() = runReducerTest {
|
fun `given directory content, when Visible, then updates shortcuts and dispatches room state`() = runReducerTest {
|
||||||
fakeShortcutHandler.instance.expectUnit { it.onDirectoryUpdate(listOf(AN_OVERVIEW)) }
|
fakeShortcutHandler.instance.expectUnit { it.onDirectoryUpdate(listOf(AN_OVERVIEW)) }
|
||||||
fakeJobBag.instance.expect { it.add("sync", any()) }
|
fakeJobBag.instance.expect { it.replace("sync", any()) }
|
||||||
fakeChatEngine.givenDirectory().returns(flowOf(listOf(AN_OVERVIEW_STATE)))
|
fakeChatEngine.givenDirectory().returns(flowOf(listOf(AN_OVERVIEW_STATE)))
|
||||||
|
|
||||||
reduce(ComponentLifecycle.OnVisible)
|
reduce(ComponentLifecycle.OnVisible)
|
||||||
|
@ -49,7 +48,7 @@ class DirectoryReducerTest {
|
||||||
@Test
|
@Test
|
||||||
fun `given no directory content, when Visible, then updates shortcuts and dispatches empty state`() = runReducerTest {
|
fun `given no directory content, when Visible, then updates shortcuts and dispatches empty state`() = runReducerTest {
|
||||||
fakeShortcutHandler.instance.expectUnit { it.onDirectoryUpdate(emptyList()) }
|
fakeShortcutHandler.instance.expectUnit { it.onDirectoryUpdate(emptyList()) }
|
||||||
fakeJobBag.instance.expect { it.add("sync", any()) }
|
fakeJobBag.instance.expect { it.replace("sync", any()) }
|
||||||
fakeChatEngine.givenDirectory().returns(flowOf(emptyList()))
|
fakeChatEngine.givenDirectory().returns(flowOf(emptyList()))
|
||||||
|
|
||||||
reduce(ComponentLifecycle.OnVisible)
|
reduce(ComponentLifecycle.OnVisible)
|
||||||
|
|
|
@ -87,9 +87,20 @@ internal fun MessengerScreen(
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
Toolbar(onNavigate = { navigator.navigate.upToHome() }, roomTitle, actions = {
|
Toolbar(onNavigate = { navigator.navigate.upToHome() }, roomTitle, actions = {
|
||||||
// OverflowMenu {
|
state.roomState.takeIfContent()?.let {
|
||||||
// DropdownMenuItem(text = { Text("Settings", color = MaterialTheme.colorScheme.onSecondaryContainer) }, onClick = {})
|
OverflowMenu {
|
||||||
// }
|
when (it.isMuted) {
|
||||||
|
true -> DropdownMenuItem(text = { Text("Unmute notifications", color = MaterialTheme.colorScheme.onSecondaryContainer) }, onClick = {
|
||||||
|
viewModel.dispatch(ScreenAction.Notifications.Unmute)
|
||||||
|
})
|
||||||
|
|
||||||
|
false -> DropdownMenuItem(text = { Text("Mute notifications", color = MaterialTheme.colorScheme.onSecondaryContainer) }, onClick = {
|
||||||
|
viewModel.dispatch(ScreenAction.Notifications.Mute)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
when (state.composerState) {
|
when (state.composerState) {
|
||||||
|
|
|
@ -10,6 +10,11 @@ sealed interface ScreenAction : Action {
|
||||||
data class CopyToClipboard(val model: BubbleModel) : ScreenAction
|
data class CopyToClipboard(val model: BubbleModel) : ScreenAction
|
||||||
object SendMessage : ScreenAction
|
object SendMessage : ScreenAction
|
||||||
object OpenGalleryPicker : ScreenAction
|
object OpenGalleryPicker : ScreenAction
|
||||||
|
|
||||||
|
sealed interface Notifications : ScreenAction {
|
||||||
|
object Mute : Notifications
|
||||||
|
object Unmute : Notifications
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed interface ComponentLifecycle : Action {
|
sealed interface ComponentLifecycle : Action {
|
||||||
|
@ -18,7 +23,8 @@ sealed interface ComponentLifecycle : Action {
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed interface MessagesStateChange : Action {
|
sealed interface MessagesStateChange : Action {
|
||||||
data class Content(val content: MessengerPageState) : ComposerStateChange
|
data class Content(val content: MessengerPageState) : MessagesStateChange
|
||||||
|
data class MuteContent(val isMuted: Boolean) : MessagesStateChange
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed interface ComposerStateChange : Action {
|
sealed interface ComposerStateChange : Action {
|
||||||
|
|
|
@ -42,9 +42,10 @@ internal fun messengerReducer(
|
||||||
val state = getState()
|
val state = getState()
|
||||||
when (action) {
|
when (action) {
|
||||||
is ComponentLifecycle.Visible -> {
|
is ComponentLifecycle.Visible -> {
|
||||||
jobBag.add("messages", chatEngine.messages(state.roomId, disableReadReceipts = messageOptionsStore.isReadReceiptsDisabled())
|
jobBag.replace(
|
||||||
.onEach { dispatch(MessagesStateChange.Content(it)) }
|
"messages", chatEngine.messages(state.roomId, disableReadReceipts = messageOptionsStore.isReadReceiptsDisabled())
|
||||||
.launchIn(coroutineScope)
|
.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,
|
timestampUtc = this.utcTimestamp,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
private fun initialComposerState(initialAttachments: List<MessageAttachment>?) = initialAttachments
|
private fun initialComposerState(initialAttachments: List<MessageAttachment>?) = initialAttachments
|
||||||
?.takeIf { it.isNotEmpty() }
|
?.takeIf { it.isNotEmpty() }
|
||||||
?.let { ComposerState.Attachments(it, null) }
|
?.let { ComposerState.Attachments(it, null) }
|
||||||
|
|
|
@ -14,7 +14,7 @@ data class MessengerScreenState(
|
||||||
val roomId: RoomId,
|
val roomId: RoomId,
|
||||||
val roomState: Lce<MessengerPageState>,
|
val roomState: Lce<MessengerPageState>,
|
||||||
val composerState: ComposerState,
|
val composerState: ComposerState,
|
||||||
val viewerState: ViewerState?
|
val viewerState: ViewerState?,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ViewerState(
|
data class ViewerState(
|
||||||
|
|
|
@ -27,6 +27,7 @@ private const val READ_RECEIPTS_ARE_DISABLED = true
|
||||||
private val A_ROOM_ID = aRoomId("messenger state room id")
|
private val A_ROOM_ID = aRoomId("messenger state room id")
|
||||||
private const val A_MESSAGE_CONTENT = "message content"
|
private const val A_MESSAGE_CONTENT = "message content"
|
||||||
private val AN_EVENT_ID = anEventId("state event")
|
private val AN_EVENT_ID = anEventId("state event")
|
||||||
|
private const val ROOM_IS_MUTED = true
|
||||||
private val A_SELF_ID = aUserId("self")
|
private val A_SELF_ID = aUserId("self")
|
||||||
private val A_MESSENGER_PAGE_STATE = aMessengerStateWithEvent(AN_EVENT_ID, A_SELF_ID)
|
private val A_MESSENGER_PAGE_STATE = aMessengerStateWithEvent(AN_EVENT_ID, A_SELF_ID)
|
||||||
private val A_MESSAGE_ATTACHMENT = MessageAttachment(AndroidUri("a-uri"), MimeType.Image)
|
private val A_MESSAGE_ATTACHMENT = MessageAttachment(AndroidUri("a-uri"), MimeType.Image)
|
||||||
|
@ -101,7 +102,7 @@ class MessengerReducerTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `given messages emits state, when Visible, then dispatches content`() = runReducerTest {
|
fun `given messages emits state, when Visible, then dispatches content`() = runReducerTest {
|
||||||
fakeJobBag.instance.expect { it.add("messages", any()) }
|
fakeJobBag.instance.expect { it.replace("messages", any()) }
|
||||||
fakeMessageOptionsStore.givenReadReceiptsDisabled().returns(READ_RECEIPTS_ARE_DISABLED)
|
fakeMessageOptionsStore.givenReadReceiptsDisabled().returns(READ_RECEIPTS_ARE_DISABLED)
|
||||||
val state = aMessengerStateWithEvent(AN_EVENT_ID, A_SELF_ID)
|
val state = aMessengerStateWithEvent(AN_EVENT_ID, A_SELF_ID)
|
||||||
fakeChatEngine.givenMessages(A_ROOM_ID, READ_RECEIPTS_ARE_DISABLED).returns(flowOf(state))
|
fakeChatEngine.givenMessages(A_ROOM_ID, READ_RECEIPTS_ARE_DISABLED).returns(flowOf(state))
|
||||||
|
@ -359,4 +360,4 @@ class FakeDeviceMeta {
|
||||||
val instance = mockk<DeviceMeta>()
|
val instance = mockk<DeviceMeta>()
|
||||||
|
|
||||||
fun givenApiVersion() = every { instance.apiVersion }.delegateReturn()
|
fun givenApiVersion() = every { instance.apiVersion }.delegateReturn()
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,13 +22,15 @@ internal class DirectoryUseCase(
|
||||||
overviewDatasource(),
|
overviewDatasource(),
|
||||||
messageService.localEchos(),
|
messageService.localEchos(),
|
||||||
roomStore.observeUnreadCountById(),
|
roomStore.observeUnreadCountById(),
|
||||||
syncService.events()
|
syncService.events(),
|
||||||
) { overviewState, localEchos, unread, events ->
|
roomStore.observeMuted(),
|
||||||
|
) { overviewState, localEchos, unread, events, muted ->
|
||||||
overviewState.mergeWithLocalEchos(localEchos, userId).map { roomOverview ->
|
overviewState.mergeWithLocalEchos(localEchos, userId).map { roomOverview ->
|
||||||
DirectoryItem(
|
DirectoryItem(
|
||||||
overview = roomOverview,
|
overview = roomOverview,
|
||||||
unreadCount = UnreadCount(unread[roomOverview.roomId] ?: 0),
|
unreadCount = UnreadCount(unread[roomOverview.roomId] ?: 0),
|
||||||
typing = events.filterIsInstance<Typing>().firstOrNull { it.roomId == roomOverview.roomId }?.engine()
|
typing = events.filterIsInstance<Typing>().firstOrNull { it.roomId == roomOverview.roomId }?.engine(),
|
||||||
|
isMuted = muted.contains(roomOverview.roomId),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,30 +3,28 @@ package app.dapk.st.engine
|
||||||
import app.dapk.st.core.Base64
|
import app.dapk.st.core.Base64
|
||||||
import app.dapk.st.core.BuildMeta
|
import app.dapk.st.core.BuildMeta
|
||||||
import app.dapk.st.core.CoroutineDispatchers
|
import app.dapk.st.core.CoroutineDispatchers
|
||||||
import app.dapk.st.core.SingletonFlows
|
|
||||||
import app.dapk.st.core.extensions.ErrorTracker
|
import app.dapk.st.core.extensions.ErrorTracker
|
||||||
import app.dapk.st.matrix.MatrixClient
|
import app.dapk.st.matrix.MatrixClient
|
||||||
import app.dapk.st.matrix.MatrixTaskRunner
|
import app.dapk.st.matrix.MatrixTaskRunner
|
||||||
import app.dapk.st.matrix.auth.DeviceDisplayNameGenerator
|
import app.dapk.st.matrix.auth.DeviceDisplayNameGenerator
|
||||||
import app.dapk.st.matrix.auth.authService
|
import app.dapk.st.matrix.auth.authService
|
||||||
import app.dapk.st.matrix.auth.installAuthService
|
import app.dapk.st.matrix.common.CredentialsStore
|
||||||
import app.dapk.st.matrix.common.*
|
import app.dapk.st.matrix.common.MatrixLogger
|
||||||
import app.dapk.st.matrix.crypto.*
|
import app.dapk.st.matrix.common.RoomId
|
||||||
|
import app.dapk.st.matrix.crypto.MatrixMediaDecrypter
|
||||||
|
import app.dapk.st.matrix.crypto.cryptoService
|
||||||
import app.dapk.st.matrix.device.KnownDeviceStore
|
import app.dapk.st.matrix.device.KnownDeviceStore
|
||||||
import app.dapk.st.matrix.device.deviceService
|
import app.dapk.st.matrix.message.BackgroundScheduler
|
||||||
import app.dapk.st.matrix.device.installEncryptionService
|
import app.dapk.st.matrix.message.LocalEchoStore
|
||||||
import app.dapk.st.matrix.http.ktor.KtorMatrixHttpClientFactory
|
|
||||||
import app.dapk.st.matrix.message.*
|
|
||||||
import app.dapk.st.matrix.message.internal.ImageContentReader
|
import app.dapk.st.matrix.message.internal.ImageContentReader
|
||||||
import app.dapk.st.matrix.push.installPushService
|
import app.dapk.st.matrix.message.messageService
|
||||||
import app.dapk.st.matrix.push.pushService
|
import app.dapk.st.matrix.push.pushService
|
||||||
import app.dapk.st.matrix.room.*
|
import app.dapk.st.matrix.room.MemberStore
|
||||||
|
import app.dapk.st.matrix.room.ProfileStore
|
||||||
|
import app.dapk.st.matrix.room.profileService
|
||||||
|
import app.dapk.st.matrix.room.roomService
|
||||||
import app.dapk.st.matrix.sync.*
|
import app.dapk.st.matrix.sync.*
|
||||||
import app.dapk.st.matrix.sync.internal.request.ApiToDeviceEvent
|
|
||||||
import app.dapk.st.matrix.sync.internal.room.MessageDecrypter
|
|
||||||
import app.dapk.st.olm.DeviceKeyFactory
|
|
||||||
import app.dapk.st.olm.OlmStore
|
import app.dapk.st.olm.OlmStore
|
||||||
import app.dapk.st.olm.OlmWrapper
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
@ -114,6 +112,10 @@ class MatrixEngine internal constructor(
|
||||||
|
|
||||||
override fun pushHandler() = matrixPushHandler.value
|
override fun pushHandler() = matrixPushHandler.value
|
||||||
|
|
||||||
|
override suspend fun muteRoom(roomId: RoomId) = matrix.value.roomService().muteRoom(roomId)
|
||||||
|
|
||||||
|
override suspend fun unmuteRoom(roomId: RoomId) = matrix.value.roomService().unmuteRoom(roomId)
|
||||||
|
|
||||||
override suspend fun runTask(task: ChatEngineTask): TaskRunner.TaskResult {
|
override suspend fun runTask(task: ChatEngineTask): TaskRunner.TaskResult {
|
||||||
return when (val result = matrix.value.run(MatrixTaskRunner.MatrixTask(task.type, task.jsonPayload))) {
|
return when (val result = matrix.value.run(MatrixTaskRunner.MatrixTask(task.type, task.jsonPayload))) {
|
||||||
is MatrixTaskRunner.TaskResult.Failure -> TaskRunner.TaskResult.Failure(result.canRetry)
|
is MatrixTaskRunner.TaskResult.Failure -> TaskRunner.TaskResult.Failure(result.canRetry)
|
||||||
|
@ -209,222 +211,4 @@ class MatrixEngine internal constructor(
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun <T> unsafeLazy(initializer: () -> T): Lazy<T> = lazy(mode = LazyThreadSafetyMode.NONE, initializer = initializer)
|
||||||
object MatrixFactory {
|
|
||||||
|
|
||||||
fun createMatrix(
|
|
||||||
base64: Base64,
|
|
||||||
buildMeta: BuildMeta,
|
|
||||||
logger: MatrixLogger,
|
|
||||||
nameGenerator: DeviceDisplayNameGenerator,
|
|
||||||
coroutineDispatchers: CoroutineDispatchers,
|
|
||||||
errorTracker: ErrorTracker,
|
|
||||||
imageContentReader: ImageContentReader,
|
|
||||||
backgroundScheduler: BackgroundScheduler,
|
|
||||||
memberStore: MemberStore,
|
|
||||||
roomStore: RoomStore,
|
|
||||||
profileStore: ProfileStore,
|
|
||||||
syncStore: SyncStore,
|
|
||||||
overviewStore: OverviewStore,
|
|
||||||
filterStore: FilterStore,
|
|
||||||
localEchoStore: LocalEchoStore,
|
|
||||||
credentialsStore: CredentialsStore,
|
|
||||||
knownDeviceStore: KnownDeviceStore,
|
|
||||||
olmStore: OlmStore,
|
|
||||||
) = MatrixClient(
|
|
||||||
KtorMatrixHttpClientFactory(
|
|
||||||
credentialsStore,
|
|
||||||
includeLogging = buildMeta.isDebug,
|
|
||||||
),
|
|
||||||
logger
|
|
||||||
).also {
|
|
||||||
it.install {
|
|
||||||
installAuthService(credentialsStore, nameGenerator)
|
|
||||||
installEncryptionService(knownDeviceStore)
|
|
||||||
|
|
||||||
val singletonFlows = SingletonFlows(coroutineDispatchers)
|
|
||||||
val olm = OlmWrapper(
|
|
||||||
olmStore = olmStore,
|
|
||||||
singletonFlows = singletonFlows,
|
|
||||||
jsonCanonicalizer = JsonCanonicalizer(),
|
|
||||||
deviceKeyFactory = DeviceKeyFactory(JsonCanonicalizer()),
|
|
||||||
errorTracker = errorTracker,
|
|
||||||
logger = logger,
|
|
||||||
clock = Clock.systemUTC(),
|
|
||||||
coroutineDispatchers = coroutineDispatchers,
|
|
||||||
)
|
|
||||||
installCryptoService(
|
|
||||||
credentialsStore,
|
|
||||||
olm,
|
|
||||||
roomMembersProvider = { services ->
|
|
||||||
RoomMembersProvider {
|
|
||||||
services.roomService().joinedMembers(it).map { it.userId }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
base64 = base64,
|
|
||||||
coroutineDispatchers = coroutineDispatchers,
|
|
||||||
)
|
|
||||||
installMessageService(
|
|
||||||
localEchoStore,
|
|
||||||
backgroundScheduler,
|
|
||||||
imageContentReader,
|
|
||||||
messageEncrypter = {
|
|
||||||
val cryptoService = it.cryptoService()
|
|
||||||
MessageEncrypter { message ->
|
|
||||||
val result = cryptoService.encrypt(
|
|
||||||
roomId = message.roomId,
|
|
||||||
credentials = credentialsStore.credentials()!!,
|
|
||||||
messageJson = message.contents,
|
|
||||||
)
|
|
||||||
|
|
||||||
MessageEncrypter.EncryptedMessagePayload(
|
|
||||||
result.algorithmName,
|
|
||||||
result.senderKey,
|
|
||||||
result.cipherText,
|
|
||||||
result.sessionId,
|
|
||||||
result.deviceId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mediaEncrypter = {
|
|
||||||
val cryptoService = it.cryptoService()
|
|
||||||
MediaEncrypter { input ->
|
|
||||||
val result = cryptoService.encrypt(input)
|
|
||||||
MediaEncrypter.Result(
|
|
||||||
uri = result.uri,
|
|
||||||
contentLength = result.contentLength,
|
|
||||||
algorithm = result.algorithm,
|
|
||||||
ext = result.ext,
|
|
||||||
keyOperations = result.keyOperations,
|
|
||||||
kty = result.kty,
|
|
||||||
k = result.k,
|
|
||||||
iv = result.iv,
|
|
||||||
hashes = result.hashes,
|
|
||||||
v = result.v,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
installRoomService(
|
|
||||||
memberStore,
|
|
||||||
roomMessenger = {
|
|
||||||
val messageService = it.messageService()
|
|
||||||
object : RoomMessenger {
|
|
||||||
override suspend fun enableEncryption(roomId: RoomId) {
|
|
||||||
messageService.sendEventMessage(
|
|
||||||
roomId, MessageService.EventMessage.Encryption(
|
|
||||||
algorithm = AlgorithmName("m.megolm.v1.aes-sha2")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
roomInviteRemover = {
|
|
||||||
overviewStore.removeInvites(listOf(it))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
installProfileService(profileStore, singletonFlows, credentialsStore)
|
|
||||||
|
|
||||||
installSyncService(
|
|
||||||
credentialsStore,
|
|
||||||
overviewStore,
|
|
||||||
roomStore,
|
|
||||||
syncStore,
|
|
||||||
filterStore,
|
|
||||||
deviceNotifier = { services ->
|
|
||||||
val encryption = services.deviceService()
|
|
||||||
val crypto = services.cryptoService()
|
|
||||||
DeviceNotifier { userIds, syncToken ->
|
|
||||||
encryption.updateStaleDevices(userIds)
|
|
||||||
crypto.updateOlmSession(userIds, syncToken)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
messageDecrypter = { serviceProvider ->
|
|
||||||
val cryptoService = serviceProvider.cryptoService()
|
|
||||||
MessageDecrypter {
|
|
||||||
cryptoService.decrypt(it)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
keySharer = { serviceProvider ->
|
|
||||||
val cryptoService = serviceProvider.cryptoService()
|
|
||||||
KeySharer { sharedRoomKeys ->
|
|
||||||
cryptoService.importRoomKeys(sharedRoomKeys)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
verificationHandler = { services ->
|
|
||||||
val cryptoService = services.cryptoService()
|
|
||||||
VerificationHandler { apiEvent ->
|
|
||||||
logger.matrixLog(MatrixLogTag.VERIFICATION, "got a verification request $it")
|
|
||||||
cryptoService.onVerificationEvent(
|
|
||||||
when (apiEvent) {
|
|
||||||
is ApiToDeviceEvent.VerificationRequest -> Verification.Event.Requested(
|
|
||||||
apiEvent.sender,
|
|
||||||
apiEvent.content.fromDevice,
|
|
||||||
apiEvent.content.transactionId,
|
|
||||||
apiEvent.content.methods,
|
|
||||||
apiEvent.content.timestampPosix,
|
|
||||||
)
|
|
||||||
|
|
||||||
is ApiToDeviceEvent.VerificationReady -> Verification.Event.Ready(
|
|
||||||
apiEvent.sender,
|
|
||||||
apiEvent.content.fromDevice,
|
|
||||||
apiEvent.content.transactionId,
|
|
||||||
apiEvent.content.methods,
|
|
||||||
)
|
|
||||||
|
|
||||||
is ApiToDeviceEvent.VerificationStart -> Verification.Event.Started(
|
|
||||||
apiEvent.sender,
|
|
||||||
apiEvent.content.fromDevice,
|
|
||||||
apiEvent.content.method,
|
|
||||||
apiEvent.content.protocols,
|
|
||||||
apiEvent.content.hashes,
|
|
||||||
apiEvent.content.codes,
|
|
||||||
apiEvent.content.short,
|
|
||||||
apiEvent.content.transactionId,
|
|
||||||
)
|
|
||||||
|
|
||||||
is ApiToDeviceEvent.VerificationCancel -> TODO()
|
|
||||||
is ApiToDeviceEvent.VerificationAccept -> TODO()
|
|
||||||
is ApiToDeviceEvent.VerificationKey -> Verification.Event.Key(
|
|
||||||
apiEvent.sender,
|
|
||||||
apiEvent.content.transactionId,
|
|
||||||
apiEvent.content.key
|
|
||||||
)
|
|
||||||
|
|
||||||
is ApiToDeviceEvent.VerificationMac -> Verification.Event.Mac(
|
|
||||||
apiEvent.sender,
|
|
||||||
apiEvent.content.transactionId,
|
|
||||||
apiEvent.content.keys,
|
|
||||||
apiEvent.content.mac,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
oneTimeKeyProducer = { services ->
|
|
||||||
val cryptoService = services.cryptoService()
|
|
||||||
MaybeCreateMoreKeys {
|
|
||||||
cryptoService.maybeCreateMoreKeys(it)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
roomMembersService = { services ->
|
|
||||||
val roomService = services.roomService()
|
|
||||||
object : RoomMembersService {
|
|
||||||
override suspend fun find(roomId: RoomId, userIds: List<UserId>) = roomService.findMembers(roomId, userIds)
|
|
||||||
override suspend fun findSummary(roomId: RoomId) = roomService.findMembersSummary(roomId)
|
|
||||||
override suspend fun insert(roomId: RoomId, members: List<RoomMember>) = roomService.insertMembers(roomId, members)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
errorTracker = errorTracker,
|
|
||||||
coroutineDispatchers = coroutineDispatchers,
|
|
||||||
)
|
|
||||||
|
|
||||||
installPushService(credentialsStore)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
fun <T> unsafeLazy(initializer: () -> T): Lazy<T> = lazy(mode = LazyThreadSafetyMode.NONE, initializer = initializer)
|
|
||||||
|
|
|
@ -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 {
|
class ObserveUnreadNotificationsUseCaseImpl(private val roomStore: RoomStore) : ObserveUnreadNotificationsUseCase {
|
||||||
|
|
||||||
override fun invoke(): Flow<UnreadNotifications> {
|
override fun invoke(): Flow<UnreadNotifications> {
|
||||||
return roomStore.observeUnread()
|
return roomStore.observeNotMutedUnread()
|
||||||
.mapWithDiff()
|
.mapWithDiff()
|
||||||
.avoidShowingPreviousNotificationsOnLaunch()
|
.avoidShowingPreviousNotificationsOnLaunch()
|
||||||
.onlyRenderableChanges()
|
.onlyRenderableChanges()
|
||||||
|
|
|
@ -23,8 +23,9 @@ internal class TimelineUseCaseImpl(
|
||||||
return combine(
|
return combine(
|
||||||
roomDatasource(roomId),
|
roomDatasource(roomId),
|
||||||
messageService.localEchos(roomId),
|
messageService.localEchos(roomId),
|
||||||
syncService.events(roomId)
|
syncService.events(roomId),
|
||||||
) { roomState, localEchos, events ->
|
roomService.observeIsMuted(roomId),
|
||||||
|
) { roomState, localEchos, events, isMuted ->
|
||||||
MessengerPageState(
|
MessengerPageState(
|
||||||
roomState = when {
|
roomState = when {
|
||||||
localEchos.isEmpty() -> roomState
|
localEchos.isEmpty() -> roomState
|
||||||
|
@ -38,6 +39,7 @@ internal class TimelineUseCaseImpl(
|
||||||
},
|
},
|
||||||
typing = events.filterIsInstance<SyncService.SyncEvent.Typing>().firstOrNull { it.roomId == roomId }?.engine(),
|
typing = events.filterIsInstance<SyncService.SyncEvent.Typing>().firstOrNull { it.roomId == roomId }?.engine(),
|
||||||
self = userId,
|
self = userId,
|
||||||
|
isMuted = isMuted,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -61,7 +61,7 @@ class ObserveUnreadRenderNotificationsUseCaseTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `given initial unreads, when receiving new message, then emits all messages`() = runTest {
|
fun `given initial unreads, when receiving new message, then emits all messages`() = runTest {
|
||||||
fakeRoomStore.givenUnreadEvents(
|
fakeRoomStore.givenNotMutedUnreadEvents(
|
||||||
flowOf(A_ROOM_OVERVIEW.withUnreads(A_MESSAGE), A_ROOM_OVERVIEW.withUnreads(A_MESSAGE, A_MESSAGE_2))
|
flowOf(A_ROOM_OVERVIEW.withUnreads(A_MESSAGE), A_ROOM_OVERVIEW.withUnreads(A_MESSAGE, A_MESSAGE_2))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -74,7 +74,7 @@ class ObserveUnreadRenderNotificationsUseCaseTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `given initial unreads, when reading a message, then emits nothing`() = runTest {
|
fun `given initial unreads, when reading a message, then emits nothing`() = runTest {
|
||||||
fakeRoomStore.givenUnreadEvents(
|
fakeRoomStore.givenNotMutedUnreadEvents(
|
||||||
flowOf(A_ROOM_OVERVIEW.withUnreads(A_MESSAGE) + A_ROOM_OVERVIEW_2.withUnreads(A_MESSAGE_2), A_ROOM_OVERVIEW.withUnreads(A_MESSAGE))
|
flowOf(A_ROOM_OVERVIEW.withUnreads(A_MESSAGE) + A_ROOM_OVERVIEW_2.withUnreads(A_MESSAGE_2), A_ROOM_OVERVIEW.withUnreads(A_MESSAGE))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -85,7 +85,7 @@ class ObserveUnreadRenderNotificationsUseCaseTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `given new and then historical message, when reading a message, then only emits the latest`() = runTest {
|
fun `given new and then historical message, when reading a message, then only emits the latest`() = runTest {
|
||||||
fakeRoomStore.givenUnreadEvents(
|
fakeRoomStore.givenNotMutedUnreadEvents(
|
||||||
flowOf(
|
flowOf(
|
||||||
NO_UNREADS,
|
NO_UNREADS,
|
||||||
A_ROOM_OVERVIEW.withUnreads(A_MESSAGE),
|
A_ROOM_OVERVIEW.withUnreads(A_MESSAGE),
|
||||||
|
@ -105,7 +105,7 @@ class ObserveUnreadRenderNotificationsUseCaseTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `given initial unreads, when reading a duplicate unread, then emits nothing`() = runTest {
|
fun `given initial unreads, when reading a duplicate unread, then emits nothing`() = runTest {
|
||||||
fakeRoomStore.givenUnreadEvents(
|
fakeRoomStore.givenNotMutedUnreadEvents(
|
||||||
flowOf(A_ROOM_OVERVIEW.withUnreads(A_MESSAGE), A_ROOM_OVERVIEW.withUnreads(A_MESSAGE))
|
flowOf(A_ROOM_OVERVIEW.withUnreads(A_MESSAGE), A_ROOM_OVERVIEW.withUnreads(A_MESSAGE))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -115,7 +115,7 @@ class ObserveUnreadRenderNotificationsUseCaseTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun givenNoInitialUnreads(vararg unreads: Map<MatrixRoomOverview, List<MatrixRoomEvent>>) =
|
private fun givenNoInitialUnreads(vararg unreads: Map<MatrixRoomOverview, List<MatrixRoomEvent>>) =
|
||||||
fakeRoomStore.givenUnreadEvents(flowOf(NO_UNREADS, *unreads))
|
fakeRoomStore.givenNotMutedUnreadEvents(flowOf(NO_UNREADS, *unreads))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Map<MatrixRoomOverview, List<MatrixRoomEvent>>.engine() = this
|
private fun Map<MatrixRoomOverview, List<MatrixRoomEvent>>.engine() = this
|
||||||
|
|
|
@ -22,6 +22,7 @@ import org.junit.Test
|
||||||
import test.FlowTestObserver
|
import test.FlowTestObserver
|
||||||
import test.delegateReturn
|
import test.delegateReturn
|
||||||
|
|
||||||
|
private const val IS_ROOM_MUTED = false
|
||||||
private val A_ROOM_ID = aRoomId()
|
private val A_ROOM_ID = aRoomId()
|
||||||
private val AN_USER_ID = aUserId()
|
private val AN_USER_ID = aUserId()
|
||||||
private val A_ROOM_STATE = aMatrixRoomState()
|
private val A_ROOM_STATE = aMatrixRoomState()
|
||||||
|
@ -63,6 +64,7 @@ class TimelineUseCaseTest {
|
||||||
|
|
||||||
fakeMergeWithLocalEchosUseCase.givenMerging(A_ROOM_STATE, A_ROOM_MEMBER, A_LOCAL_ECHOS_LIST).returns(A_MERGED_ROOM_STATE.engine())
|
fakeMergeWithLocalEchosUseCase.givenMerging(A_ROOM_STATE, A_ROOM_MEMBER, A_LOCAL_ECHOS_LIST).returns(A_MERGED_ROOM_STATE.engine())
|
||||||
|
|
||||||
|
|
||||||
timelineUseCase.invoke(A_ROOM_ID, AN_USER_ID)
|
timelineUseCase.invoke(A_ROOM_ID, AN_USER_ID)
|
||||||
.test(this)
|
.test(this)
|
||||||
.assertValues(
|
.assertValues(
|
||||||
|
@ -103,6 +105,7 @@ class TimelineUseCaseTest {
|
||||||
fakeSyncService.givenRoom(A_ROOM_ID).returns(flowOf(roomState))
|
fakeSyncService.givenRoom(A_ROOM_ID).returns(flowOf(roomState))
|
||||||
fakeMessageService.givenEchos(A_ROOM_ID).returns(flowOf(echos))
|
fakeMessageService.givenEchos(A_ROOM_ID).returns(flowOf(echos))
|
||||||
fakeSyncService.givenEvents(A_ROOM_ID).returns(flowOf(events))
|
fakeSyncService.givenEvents(A_ROOM_ID).returns(flowOf(events))
|
||||||
|
fakeRoomService.givenMuted(A_ROOM_ID).returns(flowOf(IS_ROOM_MUTED))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -129,10 +132,12 @@ class FakeMessageService : MessageService by mockk() {
|
||||||
|
|
||||||
class FakeRoomService : RoomService by mockk() {
|
class FakeRoomService : RoomService by mockk() {
|
||||||
fun givenFindMember(roomId: RoomId, userId: UserId) = coEvery { findMember(roomId, userId) }.delegateReturn()
|
fun givenFindMember(roomId: RoomId, userId: UserId) = coEvery { findMember(roomId, userId) }.delegateReturn()
|
||||||
|
fun givenMuted(roomId: RoomId) = every { observeIsMuted(roomId) }.delegateReturn()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun aMessengerState(
|
fun aMessengerState(
|
||||||
self: UserId = aUserId(),
|
self: UserId = aUserId(),
|
||||||
roomState: app.dapk.st.engine.RoomState,
|
roomState: app.dapk.st.engine.RoomState,
|
||||||
typing: Typing? = null
|
typing: Typing? = null,
|
||||||
) = MessengerPageState(self, roomState, typing)
|
isMuted: Boolean = IS_ROOM_MUTED,
|
||||||
|
) = MessengerPageState(self, roomState, typing, isMuted)
|
|
@ -5,10 +5,8 @@ import app.dapk.st.matrix.common.EventId
|
||||||
import app.dapk.st.matrix.common.RoomId
|
import app.dapk.st.matrix.common.RoomId
|
||||||
import app.dapk.st.matrix.common.RoomMember
|
import app.dapk.st.matrix.common.RoomMember
|
||||||
import app.dapk.st.matrix.common.UserId
|
import app.dapk.st.matrix.common.UserId
|
||||||
import app.dapk.st.matrix.room.internal.DefaultRoomService
|
import app.dapk.st.matrix.room.internal.*
|
||||||
import app.dapk.st.matrix.room.internal.RoomInviteRemover
|
import kotlinx.coroutines.flow.Flow
|
||||||
import app.dapk.st.matrix.room.internal.RoomMembers
|
|
||||||
import app.dapk.st.matrix.room.internal.RoomMembersCache
|
|
||||||
|
|
||||||
private val SERVICE_KEY = RoomService::class
|
private val SERVICE_KEY = RoomService::class
|
||||||
|
|
||||||
|
@ -27,6 +25,10 @@ interface RoomService : MatrixService {
|
||||||
suspend fun joinRoom(roomId: RoomId)
|
suspend fun joinRoom(roomId: RoomId)
|
||||||
suspend fun rejectJoinRoom(roomId: RoomId)
|
suspend fun rejectJoinRoom(roomId: RoomId)
|
||||||
|
|
||||||
|
suspend fun muteRoom(roomId: RoomId)
|
||||||
|
suspend fun unmuteRoom(roomId: RoomId)
|
||||||
|
fun observeIsMuted(roomId: RoomId): Flow<Boolean>
|
||||||
|
|
||||||
data class JoinedMember(
|
data class JoinedMember(
|
||||||
val userId: UserId,
|
val userId: UserId,
|
||||||
val displayName: String?,
|
val displayName: String?,
|
||||||
|
@ -39,6 +41,7 @@ fun MatrixServiceInstaller.installRoomService(
|
||||||
memberStore: MemberStore,
|
memberStore: MemberStore,
|
||||||
roomMessenger: ServiceDepFactory<RoomMessenger>,
|
roomMessenger: ServiceDepFactory<RoomMessenger>,
|
||||||
roomInviteRemover: RoomInviteRemover,
|
roomInviteRemover: RoomInviteRemover,
|
||||||
|
singleRoomStore: SingleRoomStore,
|
||||||
): InstallExtender<RoomService> {
|
): InstallExtender<RoomService> {
|
||||||
return this.install { (httpClient, _, services, logger) ->
|
return this.install { (httpClient, _, services, logger) ->
|
||||||
SERVICE_KEY to DefaultRoomService(
|
SERVICE_KEY to DefaultRoomService(
|
||||||
|
@ -46,7 +49,8 @@ fun MatrixServiceInstaller.installRoomService(
|
||||||
logger,
|
logger,
|
||||||
RoomMembers(memberStore, RoomMembersCache()),
|
RoomMembers(memberStore, RoomMembersCache()),
|
||||||
roomMessenger.create(services),
|
roomMessenger.create(services),
|
||||||
roomInviteRemover
|
roomInviteRemover,
|
||||||
|
singleRoomStore,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ import app.dapk.st.matrix.room.RoomMessenger
|
||||||
import app.dapk.st.matrix.room.RoomService
|
import app.dapk.st.matrix.room.RoomService
|
||||||
import io.ktor.client.plugins.*
|
import io.ktor.client.plugins.*
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@ -19,6 +20,7 @@ class DefaultRoomService(
|
||||||
private val roomMembers: RoomMembers,
|
private val roomMembers: RoomMembers,
|
||||||
private val roomMessenger: RoomMessenger,
|
private val roomMessenger: RoomMessenger,
|
||||||
private val roomInviteRemover: RoomInviteRemover,
|
private val roomInviteRemover: RoomInviteRemover,
|
||||||
|
private val singleRoomStore: SingleRoomStore,
|
||||||
) : RoomService {
|
) : RoomService {
|
||||||
|
|
||||||
override suspend fun joinedMembers(roomId: RoomId): List<RoomService.JoinedMember> {
|
override suspend fun joinedMembers(roomId: RoomId): List<RoomService.JoinedMember> {
|
||||||
|
@ -82,6 +84,7 @@ class DefaultRoomService(
|
||||||
} else {
|
} else {
|
||||||
throw it
|
throw it
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> throw it
|
else -> throw it
|
||||||
|
@ -90,6 +93,22 @@ class DefaultRoomService(
|
||||||
)
|
)
|
||||||
roomInviteRemover.remove(roomId)
|
roomInviteRemover.remove(roomId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun muteRoom(roomId: RoomId) {
|
||||||
|
singleRoomStore.mute(roomId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun unmuteRoom(roomId: RoomId) {
|
||||||
|
singleRoomStore.unmute(roomId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun observeIsMuted(roomId: RoomId): Flow<Boolean> = singleRoomStore.isMuted(roomId)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SingleRoomStore {
|
||||||
|
suspend fun mute(roomId: RoomId)
|
||||||
|
suspend fun unmute(roomId: RoomId)
|
||||||
|
fun isMuted(roomId: RoomId): Flow<Boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun joinedMembersRequest(roomId: RoomId) = httpRequest<JoinedMembersResponse>(
|
internal fun joinedMembersRequest(roomId: RoomId) = httpRequest<JoinedMembersResponse>(
|
||||||
|
|
|
@ -5,7 +5,7 @@ import app.dapk.st.matrix.common.RoomId
|
||||||
import app.dapk.st.matrix.common.SyncToken
|
import app.dapk.st.matrix.common.SyncToken
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
interface RoomStore {
|
interface RoomStore : MuteableStore {
|
||||||
|
|
||||||
suspend fun persist(roomId: RoomId, events: List<RoomEvent>)
|
suspend fun persist(roomId: RoomId, events: List<RoomEvent>)
|
||||||
suspend fun remove(rooms: List<RoomId>)
|
suspend fun remove(rooms: List<RoomId>)
|
||||||
|
@ -16,11 +16,19 @@ interface RoomStore {
|
||||||
suspend fun markRead(roomId: RoomId)
|
suspend fun markRead(roomId: RoomId)
|
||||||
fun observeUnread(): Flow<Map<RoomOverview, List<RoomEvent>>>
|
fun observeUnread(): Flow<Map<RoomOverview, List<RoomEvent>>>
|
||||||
fun observeUnreadCountById(): Flow<Map<RoomId, Int>>
|
fun observeUnreadCountById(): Flow<Map<RoomId, Int>>
|
||||||
|
fun observeNotMutedUnread(): Flow<Map<RoomOverview, List<RoomEvent>>>
|
||||||
fun observeEvent(eventId: EventId): Flow<EventId>
|
fun observeEvent(eventId: EventId): Flow<EventId>
|
||||||
suspend fun findEvent(eventId: EventId): RoomEvent?
|
suspend fun findEvent(eventId: EventId): RoomEvent?
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MuteableStore {
|
||||||
|
suspend fun mute(roomId: RoomId)
|
||||||
|
suspend fun unmute(roomId: RoomId)
|
||||||
|
suspend fun isMuted(roomId: RoomId): Boolean
|
||||||
|
fun observeMuted(): Flow<Set<RoomId>>
|
||||||
|
}
|
||||||
|
|
||||||
interface FilterStore {
|
interface FilterStore {
|
||||||
|
|
||||||
suspend fun store(key: String, filterId: String)
|
suspend fun store(key: String, filterId: String)
|
||||||
|
|
|
@ -26,15 +26,18 @@ internal class UnreadEventsProcessor(
|
||||||
isInitialSync -> {
|
isInitialSync -> {
|
||||||
// let's assume everything is read
|
// let's assume everything is read
|
||||||
}
|
}
|
||||||
|
|
||||||
previousState?.readMarker != overview.readMarker -> {
|
previousState?.readMarker != overview.readMarker -> {
|
||||||
// assume the user has viewed the room
|
// assume the user has viewed the room
|
||||||
logger.matrixLog(MatrixLogTag.SYNC, "marking room read due to new read marker")
|
logger.matrixLog(MatrixLogTag.SYNC, "marking room read due to new read marker")
|
||||||
roomStore.markRead(overview.roomId)
|
roomStore.markRead(overview.roomId)
|
||||||
}
|
}
|
||||||
|
|
||||||
areWeViewingRoom -> {
|
areWeViewingRoom -> {
|
||||||
logger.matrixLog(MatrixLogTag.SYNC, "marking room read")
|
logger.matrixLog(MatrixLogTag.SYNC, "marking room read")
|
||||||
roomStore.markRead(overview.roomId)
|
roomStore.markRead(overview.roomId)
|
||||||
}
|
}
|
||||||
|
|
||||||
newEvents.isNotEmpty() -> {
|
newEvents.isNotEmpty() -> {
|
||||||
logger.matrixLog(MatrixLogTag.SYNC, "insert new unread events")
|
logger.matrixLog(MatrixLogTag.SYNC, "insert new unread events")
|
||||||
|
|
||||||
|
|
|
@ -34,4 +34,8 @@ class FakeRoomStore : RoomStore by mockk() {
|
||||||
every { observeUnread() } returns unreadEvents
|
every { observeUnread() } returns unreadEvents
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun givenNotMutedUnreadEvents(unreadEvents: Flow<Map<RoomOverview, List<RoomEvent>>>) {
|
||||||
|
every { observeNotMutedUnread() } returns unreadEvents
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
|
@ -23,6 +23,7 @@ import app.dapk.st.matrix.message.internal.ImageContentReader
|
||||||
import app.dapk.st.matrix.push.installPushService
|
import app.dapk.st.matrix.push.installPushService
|
||||||
import app.dapk.st.matrix.room.RoomMessenger
|
import app.dapk.st.matrix.room.RoomMessenger
|
||||||
import app.dapk.st.matrix.room.installRoomService
|
import app.dapk.st.matrix.room.installRoomService
|
||||||
|
import app.dapk.st.matrix.room.internal.SingleRoomStore
|
||||||
import app.dapk.st.matrix.room.roomService
|
import app.dapk.st.matrix.room.roomService
|
||||||
import app.dapk.st.matrix.sync.*
|
import app.dapk.st.matrix.sync.*
|
||||||
import app.dapk.st.matrix.sync.internal.request.ApiToDeviceEvent
|
import app.dapk.st.matrix.sync.internal.request.ApiToDeviceEvent
|
||||||
|
@ -31,6 +32,9 @@ import app.dapk.st.olm.DeviceKeyFactory
|
||||||
import app.dapk.st.olm.OlmPersistenceWrapper
|
import app.dapk.st.olm.OlmPersistenceWrapper
|
||||||
import app.dapk.st.olm.OlmWrapper
|
import app.dapk.st.olm.OlmWrapper
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import org.amshove.kluent.fail
|
import org.amshove.kluent.fail
|
||||||
import test.impl.InMemoryDatabase
|
import test.impl.InMemoryDatabase
|
||||||
|
@ -178,7 +182,8 @@ class TestMatrix(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
roomInviteRemover = { storeModule.overviewStore().removeInvites(listOf(it)) }
|
roomInviteRemover = { storeModule.overviewStore().removeInvites(listOf(it)) },
|
||||||
|
singleRoomStore = singleRoomStoreAdapter(storeModule.roomStore())
|
||||||
)
|
)
|
||||||
|
|
||||||
installSyncService(
|
installSyncService(
|
||||||
|
@ -378,4 +383,10 @@ class ProxyDeviceService(private val deviceService: DeviceService) : DeviceServi
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun singleRoomStoreAdapter(roomStore: RoomStore) = object : SingleRoomStore {
|
||||||
|
override suspend fun mute(roomId: RoomId) = roomStore.mute(roomId)
|
||||||
|
override suspend fun unmute(roomId: RoomId) = roomStore.unmute(roomId)
|
||||||
|
override fun isMuted(roomId: RoomId): Flow<Boolean> = roomStore.observeMuted().map { it.contains(roomId) }.distinctUntilChanged()
|
||||||
|
}
|
||||||
|
|
||||||
fun MatrixClient.proxyDeviceService() = this.deviceService() as ProxyDeviceService
|
fun MatrixClient.proxyDeviceService() = this.deviceService() as ProxyDeviceService
|
|
@ -1,7 +1,6 @@
|
||||||
package test.impl
|
package test.impl
|
||||||
|
|
||||||
import app.dapk.st.core.Preferences
|
import app.dapk.st.core.Preferences
|
||||||
import test.unit
|
|
||||||
|
|
||||||
class InMemoryPreferences : Preferences {
|
class InMemoryPreferences : Preferences {
|
||||||
|
|
||||||
|
@ -12,7 +11,13 @@ class InMemoryPreferences : Preferences {
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun readString(key: String): String? = prefs[key]
|
override suspend fun readString(key: String): String? = prefs[key]
|
||||||
override suspend fun remove(key: String) = prefs.remove(key).unit()
|
|
||||||
override suspend fun clear() = prefs.clear()
|
override suspend fun remove(key: String) {
|
||||||
|
prefs.remove(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clear() {
|
||||||
|
prefs.clear()
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
Loading…
Reference in New Issue