Merge pull request #13 from ouchadam/feature/invitations

Invitations
This commit is contained in:
Adam Brown 2022-03-17 23:19:40 +00:00
commit d2891cac69
28 changed files with 375 additions and 78 deletions

View File

@ -171,7 +171,7 @@ internal class FeatureModules internal constructor(
coroutineDispatchers
)
}
val profileModule by unsafeLazy { ProfileModule(matrixModules.profile, matrixModules.sync) }
val profileModule by unsafeLazy { ProfileModule(matrixModules.profile, matrixModules.sync, matrixModules.room) }
val notificationsModule by unsafeLazy {
NotificationsModule(
matrixModules.push,

View File

@ -27,14 +27,13 @@ fun <T : Any> Spider(currentPage: SpiderPage<T>, onNavigate: (SpiderPage<out T>?
}
Column {
Toolbar(
onNavigate = navigateAndPopStack,
title = currentPage.label
)
currentPage.parent?.let {
BackHandler(onBack = navigateAndPopStack)
if (currentPage.hasToolbar) {
Toolbar(
onNavigate = navigateAndPopStack,
title = currentPage.label
)
}
BackHandler(onBack = navigateAndPopStack)
computedWeb[currentPage.route]!!.invoke(currentPage.state)
}
}

View File

@ -13,7 +13,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun TextRow(title: String, content: String? = null, includeDivider: Boolean = true, onClick: (() -> Unit)? = null) {
fun TextRow(title: String, content: String? = null, includeDivider: Boolean = true, onClick: (() -> Unit)? = null, body: @Composable () -> Unit = {}) {
val modifier = Modifier.padding(horizontal = 24.dp)
Column(
Modifier
@ -31,6 +31,7 @@ fun TextRow(title: String, content: String? = null, includeDivider: Boolean = tr
Text(text = content, fontSize = 18.sp)
}
}
body()
Spacer(modifier = Modifier.height(24.dp))
}
if (includeDivider) {

View File

@ -11,10 +11,8 @@ import app.dapk.st.matrix.sync.RoomInvite
import app.dapk.st.matrix.sync.RoomOverview
import com.squareup.sqldelight.runtime.coroutines.asFlow
import com.squareup.sqldelight.runtime.coroutines.mapToList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
private val json = Json
@ -31,11 +29,22 @@ internal class OverviewPersistence(
.map { it.map { json.decodeFromString(RoomOverview.serializer(), it.blob) } }
}
override suspend fun removeRooms(roomsToRemove: List<RoomId>) {
dispatchers.withIoContext {
database.transaction {
roomsToRemove.forEach {
database.inviteStateQueries.remove(it.value)
database.overviewStateQueries.remove(it.value)
}
}
}
}
override suspend fun persistInvites(invites: List<RoomInvite>) {
dispatchers.withIoContext {
database.inviteStateQueries.transaction {
invites.forEach {
database.inviteStateQueries.insert(it.roomId.value)
database.inviteStateQueries.insert(it.roomId.value, json.encodeToString(RoomInvite.serializer(), it))
}
}
}
@ -45,7 +54,15 @@ internal class OverviewPersistence(
return database.inviteStateQueries.selectAll()
.asFlow()
.mapToList()
.map { it.map { RoomInvite(RoomId(it)) } }
.map { it.map { json.decodeFromString(RoomInvite.serializer(), it.blob) } }
}
override suspend fun removeInvites(invites: List<RoomId>) {
dispatchers.withIoContext {
database.inviteStateQueries.transaction {
invites.forEach { database.inviteStateQueries.remove(it.value) }
}
}
}
override suspend fun persist(overviewState: OverviewState) {
@ -59,7 +76,7 @@ internal class OverviewPersistence(
}
override suspend fun retrieve(): OverviewState {
return withContext(Dispatchers.IO) {
return dispatchers.withIoContext {
val overviews = database.overviewStateQueries.selectAll().executeAsList()
overviews.map { json.decodeFromString(RoomOverview.serializer(), it.blob) }
}

View File

@ -37,6 +37,13 @@ internal class RoomPersistence(
}
}
override suspend fun remove(rooms: List<RoomId>) {
coroutineDispatchers
database.roomEventQueries.transaction {
rooms.forEach { database.roomEventQueries.remove(it.value) }
}
}
override fun latest(roomId: RoomId): Flow<RoomState> {
val overviewFlow = database.overviewStateQueries.selectRoom(roomId.value).asFlow().mapToOneNotNull().map {
json.decodeFromString(RoomOverview.serializer(), it)

View File

@ -1,12 +1,17 @@
CREATE TABLE dbInviteState (
room_id TEXT NOT NULL,
blob TEXT NOT NULL,
PRIMARY KEY (room_id)
);
selectAll:
SELECT room_id
SELECT room_id, blob
FROM dbInviteState;
insert:
INSERT OR REPLACE INTO dbInviteState(room_id)
VALUES (?);
INSERT OR REPLACE INTO dbInviteState(room_id, blob)
VALUES (?, ?);
remove:
DELETE FROM dbInviteState
WHERE room_id = ?;

View File

@ -18,4 +18,8 @@ WHERE room_id = ?;
insert:
INSERT OR REPLACE INTO dbOverviewState(room_id, latest_activity_timestamp_utc, read_marker, blob)
VALUES (?, ?, ?, ?);
VALUES (?, ?, ?, ?);
remove:
DELETE FROM dbOverviewState
WHERE room_id = ?;

View File

@ -30,4 +30,10 @@ WHERE event_id = ?;
selectAllUnread:
SELECT dbRoomEvent.blob, dbRoomEvent.room_id
FROM dbUnreadEvent
INNER JOIN dbRoomEvent ON dbUnreadEvent.event_id = dbRoomEvent.event_id;
INNER JOIN dbRoomEvent ON dbUnreadEvent.event_id = dbRoomEvent.event_id
ORDER BY dbRoomEvent.timestamp_utc DESC
LIMIT 100;
remove:
DELETE FROM dbRoomEvent
WHERE room_id = ?;

View File

@ -1,6 +1,5 @@
package app.dapk.st.home
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.Composable
@ -38,8 +37,9 @@ fun HomeScreen(homeViewModel: HomeViewModel) {
when (state.page) {
Directory -> DirectoryScreen(homeViewModel.directory())
Profile -> {
BackHandler { homeViewModel.changePage(Directory) }
ProfileScreen(homeViewModel.profile())
ProfileScreen(homeViewModel.profile()) {
homeViewModel.changePage(Directory)
}
}
}
}

View File

@ -15,7 +15,7 @@ class NotificationRenderer(
private val notificationFactory: NotificationFactory,
) {
suspend fun render(result: Map<RoomOverview, List<RoomEvent>>, removedRooms: Set<RoomId>) {
suspend fun render(result: Map<RoomOverview, List<RoomEvent>>, removedRooms: Set<RoomId>, onlyContainsRemovals: Boolean) {
removedRooms.forEach { notificationManager.cancel(it.value, MESSAGE_NOTIFICATION_ID) }
val notifications = notificationFactory.createNotifications(result)
@ -26,7 +26,11 @@ class NotificationRenderer(
notifications.delegates.forEach {
when (it) {
is NotificationDelegate.DismissRoom -> notificationManager.cancel(it.roomId.value, MESSAGE_NOTIFICATION_ID)
is NotificationDelegate.Room -> notificationManager.notify(it.roomId.value, MESSAGE_NOTIFICATION_ID, it.notification)
is NotificationDelegate.Room -> {
if (!onlyContainsRemovals) {
notificationManager.notify(it.roomId.value, MESSAGE_NOTIFICATION_ID, it.notification)
}
}
}
}

View File

@ -3,6 +3,7 @@ package app.dapk.st.notifications
import app.dapk.st.core.AppLogTag.NOTIFICATION
import app.dapk.st.core.log
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.sync.RoomEvent
import app.dapk.st.matrix.sync.RoomStore
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.drop
@ -14,7 +15,7 @@ class NotificationsUseCase(
notificationChannels: NotificationChannels,
) {
private val inferredCurrentNotifications = mutableSetOf<RoomId>()
private val inferredCurrentNotifications = mutableMapOf<RoomId, List<RoomEvent>>()
init {
notificationChannels.initChannels()
@ -26,13 +27,17 @@ class NotificationsUseCase(
.onEach { result ->
log(NOTIFICATION, "unread changed - render notifications")
val asRooms = result.keys.map { it.roomId }.toSet()
val removedRooms = inferredCurrentNotifications - asRooms
val changes = result.mapKeys { it.key.roomId }
val asRooms = changes.keys
val removedRooms = inferredCurrentNotifications.keys - asRooms
val onlyContainsRemovals =
inferredCurrentNotifications.filterKeys { !removedRooms.contains(it) } == changes.filterKeys { !removedRooms.contains(it) }
inferredCurrentNotifications.clear()
inferredCurrentNotifications.addAll(asRooms)
inferredCurrentNotifications.putAll(changes)
notificationRenderer.render(result, removedRooms)
notificationRenderer.render(result, removedRooms, onlyContainsRemovals)
}
.collect()
}

View File

@ -28,8 +28,10 @@ class PushAndroidService : FirebaseMessagingService() {
}
override fun onNewToken(token: String) {
log(PUSH, "new push token received")
GlobalScope.launch {
module.pushUseCase().registerPush(token)
log(PUSH, "token registered")
}
}

View File

@ -2,6 +2,7 @@ applyAndroidLibraryModule(project)
dependencies {
implementation project(":matrix:services:sync")
implementation project(":matrix:services:room")
implementation project(":matrix:services:profile")
implementation project(":features:settings")
implementation project(':domains:store')

View File

@ -2,15 +2,17 @@ package app.dapk.st.profile
import app.dapk.st.core.ProvidableModule
import app.dapk.st.matrix.room.ProfileService
import app.dapk.st.matrix.room.RoomService
import app.dapk.st.matrix.sync.SyncService
class ProfileModule(
private val profileService: ProfileService,
private val syncService: SyncService,
private val roomService: RoomService,
) : ProvidableModule {
fun profileViewModel(): ProfileViewModel {
return ProfileViewModel(profileService, syncService)
return ProfileViewModel(profileService, syncService, roomService)
}
}

View File

@ -1,12 +1,13 @@
package app.dapk.st.profile
import android.content.Context
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material.icons.filled.Settings
@ -16,25 +17,44 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import app.dapk.st.core.Lce
import app.dapk.st.core.LifecycleEffect
import app.dapk.st.core.StartObserving
import app.dapk.st.core.components.CenteredLoading
import app.dapk.st.design.components.CircleishAvatar
import app.dapk.st.design.components.Spider
import app.dapk.st.design.components.TextRow
import app.dapk.st.design.components.percentOfHeight
import app.dapk.st.design.components.*
import app.dapk.st.matrix.sync.InviteMeta
import app.dapk.st.matrix.sync.RoomInvite
import app.dapk.st.settings.SettingsActivity
@Composable
fun ProfileScreen(viewModel: ProfileViewModel) {
fun ProfileScreen(viewModel: ProfileViewModel, onTopLevelBack: () -> Unit) {
viewModel.ObserveEvents()
LifecycleEffect(onStart = {
viewModel.start()
})
LifecycleEffect(
onStart = { viewModel.start() },
onStop = { viewModel.stop() }
)
val context = LocalContext.current
val onNavigate: (SpiderPage<out Page>?) -> Unit = {
when (it) {
null -> onTopLevelBack()
else -> viewModel.goTo(it)
}
}
Spider(currentPage = viewModel.state.page, onNavigate = onNavigate) {
item(Page.Routes.profile) {
ProfilePage(context, viewModel, it)
}
item(Page.Routes.invitation) {
Invitations(viewModel, it)
}
}
}
@Composable
private fun ProfilePage(context: Context, viewModel: ProfileViewModel, profile: Page.Profile) {
Box(
modifier = Modifier
.fillMaxWidth()
@ -45,20 +65,20 @@ fun ProfileScreen(viewModel: ProfileViewModel) {
}
}
when (val state = viewModel.state) {
ProfileScreenState.Loading -> CenteredLoading()
is ProfileScreenState.Content -> {
when (val state = profile.content) {
is Lce.Loading -> CenteredLoading()
is Lce.Content -> {
val configuration = LocalConfiguration.current
val content = state.value
Column {
Spacer(modifier = Modifier.fillMaxHeight(0.05f))
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
val fallbackLabel = state.me.displayName ?: state.me.userId.value
val fallbackLabel = content.me.displayName ?: content.me.userId.value
val avatarSize = configuration.percentOfHeight(0.2f)
Box {
CircleishAvatar(state.me.avatarUrl?.value, fallbackLabel, avatarSize)
CircleishAvatar(content.me.avatarUrl?.value, fallbackLabel, avatarSize)
// TODO enable once edit support it added
// TODO enable once edit support is added
if (false) {
IconButton(modifier = Modifier
.size(avatarSize * 0.314f)
@ -76,26 +96,61 @@ fun ProfileScreen(viewModel: ProfileViewModel) {
TextRow(
title = "Display name",
content = state.me.displayName ?: "Not set",
content = content.me.displayName ?: "Not set",
)
TextRow(
title = "User id",
content = state.me.userId.value,
content = content.me.userId.value,
)
TextRow(
title = "Homeserver",
content = state.me.homeServerUrl.value,
content = content.me.homeServerUrl.value,
)
TextRow(
title = "Invitations",
content = "${state.invitationsCount} pending",
content = "${content.invitationsCount} pending",
onClick = { viewModel.goToInvitations() }
)
}
}
}
}
@Composable
private fun Invitations(viewModel: ProfileViewModel, invitations: Page.Invitations) {
when (val state = invitations.content) {
is Lce.Loading -> CenteredLoading()
is Lce.Content -> {
LazyColumn {
items(state.value) {
val text = when (val meta = it.inviteMeta) {
InviteMeta.DirectMessage -> "${it.inviterName()} has invited you to chat"
is InviteMeta.Room -> "${it.inviterName()} has invited you to ${meta.roomName ?: "unnamed room"}"
}
TextRow(title = text, includeDivider = false) {
Spacer(modifier = Modifier.height(4.dp))
Row {
Button(modifier = Modifier.weight(1f), onClick = { viewModel.rejectRoomInvite(it.roomId) }) {
Text("Reject".uppercase())
}
Spacer(modifier = Modifier.fillMaxWidth(0.1f))
Button(modifier = Modifier.weight(1f), onClick = { viewModel.acceptRoomInvite(it.roomId) }) {
Text("Accept".uppercase())
}
}
}
}
}
}
is Lce.Error -> TODO()
}
}
private fun RoomInvite.inviterName() = this.from.displayName?.let { "$it (${this.from.id.value})" } ?: this.from.id.value
@Composable
private fun ProfileViewModel.ObserveEvents() {
val context = LocalContext.current

View File

@ -1,14 +1,30 @@
package app.dapk.st.profile
import app.dapk.st.core.Lce
import app.dapk.st.design.components.Route
import app.dapk.st.design.components.SpiderPage
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.room.ProfileService
import app.dapk.st.matrix.sync.RoomInvite
sealed interface ProfileScreenState {
object Loading : ProfileScreenState
data class Content(
val me: ProfileService.Me,
val invitationsCount: Int,
) : ProfileScreenState
data class ProfileScreenState(
val page: SpiderPage<out Page>,
)
sealed interface Page {
data class Profile(val content: Lce<Content>) : Page {
data class Content(
val me: ProfileService.Me,
val invitationsCount: Int,
)
}
data class Invitations(val content: Lce<List<RoomInvite>>): Page
object Routes {
val profile = Route<Profile>("Profile")
val invitation = Route<Invitations>("Invitations")
}
}
sealed interface ProfileEvent {

View File

@ -1,27 +1,74 @@
package app.dapk.st.profile
import androidx.lifecycle.viewModelScope
import app.dapk.st.core.Lce
import app.dapk.st.design.components.SpiderPage
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.room.ProfileService
import app.dapk.st.matrix.room.RoomService
import app.dapk.st.matrix.sync.SyncService
import app.dapk.st.viewmodel.DapkViewModel
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
class ProfileViewModel(
private val profileService: ProfileService,
private val syncService: SyncService,
private val roomService: RoomService,
) : DapkViewModel<ProfileScreenState, ProfileEvent>(
initialState = ProfileScreenState.Loading
ProfileScreenState(SpiderPage(Page.Routes.profile, "Profile", null, Page.Profile(Lce.Loading()), hasToolbar = false))
) {
private var syncingJob: Job? = null
private var currentPageJob: Job? = null
fun start() {
viewModelScope.launch {
val invitationsCount = syncService.invites().firstOrNull()?.size ?: 0
val me = profileService.me(forceRefresh = true)
state = ProfileScreenState.Content(me, invitationsCount = invitationsCount)
goToProfile()
}
private fun goToProfile() {
syncingJob = syncService.startSyncing().launchIn(viewModelScope)
combine(
flow { emit(profileService.me(forceRefresh = true)) },
syncService.invites(),
transform = { me, invites -> me to invites }
)
.onEach { (me, invites) ->
updatePageState<Page.Profile> {
copy(content = Lce.Content(Page.Profile.Content(me, invites.size)))
}
}
.launchPageJob()
}
fun goToInvitations() {
updateState { copy(page = SpiderPage(Page.Routes.invitation, "Invitations", Page.Routes.profile, Page.Invitations(Lce.Loading()))) }
syncService.invites()
.onEach {
updatePageState<Page.Invitations> {
copy(content = Lce.Content(it))
}
}
.launchPageJob()
}
fun goTo(page: SpiderPage<out Page>) {
currentPageJob?.cancel()
updateState { copy(page = page) }
when (page.state) {
is Page.Invitations -> goToInvitations()
is Page.Profile -> goToProfile()
}
}
private fun <T> Flow<T>.launchPageJob() {
currentPageJob?.cancel()
currentPageJob = this.launchIn(viewModelScope)
}
fun updateDisplayName() {
// TODO
@ -31,4 +78,40 @@ class ProfileViewModel(
// TODO
}
fun acceptRoomInvite(roomId: RoomId) {
launchCatching { roomService.joinRoom(roomId) }.fold(
onError = {}
)
}
fun rejectRoomInvite(roomId: RoomId) {
launchCatching { roomService.rejectJoinRoom(roomId) }.fold(
onError = {}
)
}
fun stop() {
syncingJob?.cancel()
}
@Suppress("UNCHECKED_CAST")
private inline fun <reified S : Page> updatePageState(crossinline block: S.() -> S) {
val page = state.page
val currentState = page.state
require(currentState is S)
updateState { copy(page = (page as SpiderPage<S>).copy(state = block(page.state))) }
}
}
fun <S, VE, T> DapkViewModel<S, VE>.launchCatching(block: suspend () -> T): LaunchCatching<T> {
return object : LaunchCatching<T> {
override fun fold(onSuccess: (T) -> Unit, onError: (Throwable) -> Unit) {
viewModelScope.launch { runCatching { block() }.fold(onSuccess, onError) }
}
}
}
interface LaunchCatching<T> {
fun fold(onSuccess: (T) -> Unit = {}, onError: (Throwable) -> Unit = {})
}

View File

@ -199,9 +199,7 @@ private fun RootSettings(page: Page.Root, onClick: (SettingItem) -> Unit) {
@Composable
private fun Encryption(viewModel: SettingsViewModel, page: Page.Security) {
Column {
TextRow("Import room keys", includeDivider = false) {
viewModel.goToImportRoom()
}
TextRow("Import room keys", includeDivider = false, onClick = { viewModel.goToImportRoom() })
}
}

View File

@ -25,6 +25,7 @@ interface RoomService : MatrixService {
suspend fun createDm(userId: UserId, encrypted: Boolean): RoomId
suspend fun joinRoom(roomId: RoomId)
suspend fun rejectJoinRoom(roomId: RoomId)
data class JoinedMember(
val userId: UserId,

View File

@ -62,6 +62,10 @@ class DefaultRoomService(
override suspend fun joinRoom(roomId: RoomId) {
httpClient.execute(joinRoomRequest(roomId))
}
override suspend fun rejectJoinRoom(roomId: RoomId) {
httpClient.execute(rejectJoinRoomRequest(roomId))
}
}
internal fun joinedMembersRequest(roomId: RoomId) = httpRequest<JoinedMembersResponse>(
@ -87,6 +91,13 @@ internal fun joinRoomRequest(roomId: RoomId) = httpRequest<Unit>(
body = emptyJsonBody()
)
internal fun rejectJoinRoomRequest(roomId: RoomId) = httpRequest<Unit>(
path = "_matrix/client/r0/rooms/${roomId.value}/leave",
method = MatrixHttpClient.Method.POST,
body = emptyJsonBody()
)
@Suppress("EnumEntryName")
@Serializable
enum class RoomVisibility {

View File

@ -31,5 +31,18 @@ data class LastMessage(
@Serializable
data class RoomInvite(
@SerialName("from") val from: RoomMember,
@SerialName("room_id") val roomId: RoomId,
@SerialName("meta") val inviteMeta: InviteMeta,
)
@Serializable
sealed class InviteMeta {
@Serializable
@SerialName("direct_message")
object DirectMessage : InviteMeta()
@Serializable
@SerialName("room")
data class Room(val roomName: String? = null) : InviteMeta()
}

View File

@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.Flow
interface RoomStore {
suspend fun persist(roomId: RoomId, state: RoomState)
suspend fun remove(rooms: List<RoomId>)
suspend fun retrieve(roomId: RoomId): RoomState?
fun latest(roomId: RoomId): Flow<RoomState>
suspend fun insertUnread(roomId: RoomId, eventIds: List<EventId>)
@ -28,6 +29,7 @@ interface FilterStore {
interface OverviewStore {
suspend fun removeRooms(roomsToRemove: List<RoomId>)
suspend fun persistInvites(invite: List<RoomInvite>)
suspend fun persist(overviewState: OverviewState)
@ -35,6 +37,7 @@ interface OverviewStore {
fun latest(): Flow<OverviewState>
fun latestInvites(): Flow<List<RoomInvite>>
suspend fun removeInvites(map: List<RoomId>)
}
interface SyncStore {

View File

@ -18,7 +18,7 @@ private val SERVICE_KEY = SyncService::class
interface SyncService : MatrixService {
suspend fun invites(): Flow<InviteState>
fun invites(): Flow<InviteState>
fun overview(): Flow<OverviewState>
fun room(roomId: RoomId): Flow<RoomState>
fun startSyncing(): Flow<Unit>

View File

@ -63,6 +63,7 @@ internal class DefaultSyncService(
EphemeralEventsUseCase(roomMembersService, syncEventsFlow),
),
roomRefresher,
roomDataSource,
logger,
coroutineDispatchers,
)
@ -100,7 +101,7 @@ internal class DefaultSyncService(
}
override fun startSyncing() = syncFlow
override suspend fun invites() = overviewStore.latestInvites()
override fun invites() = overviewStore.latestInvites()
override fun overview() = overviewStore.latest()
override fun room(roomId: RoomId) = roomStore.latest(roomId)
override fun events() = syncEventsFlow

View File

@ -214,6 +214,7 @@ sealed class ApiToDeviceEvent {
internal data class ApiSyncRooms(
@SerialName("join") val join: Map<RoomId, ApiSyncRoom>? = null,
@SerialName("invite") val invite: Map<RoomId, ApiSyncRoomInvite>? = null,
@SerialName("leave") val leave: Map<RoomId, ApiSyncRoom>? = null,
)
@Serializable
@ -230,14 +231,30 @@ internal data class ApiInviteEvents(
sealed class ApiStrippedEvent {
@Serializable
@SerialName("m.room.create")
internal data class RoomCreate(
@SerialName("m.room.member")
internal data class RoomMember(
@SerialName("content") val content: Content,
@SerialName("sender") val sender: UserId,
) : ApiStrippedEvent() {
@Serializable
internal data class Content(
@SerialName("displayname") val displayName: String? = null,
@SerialName("membership") val membership: ApiTimelineEvent.RoomMember.Content.Membership? = null,
@SerialName("is_direct") val isDirect: Boolean? = null,
@SerialName("avatar_url") val avatarUrl: MxUrl? = null,
)
}
@Serializable
@SerialName("m.room.name")
internal data class RoomName(
@SerialName("content") val content: Content,
) : ApiStrippedEvent() {
@Serializable
internal data class Content(
@SerialName("type") val type: String? = null
@SerialName("name") val name: String? = null
)
}
@ -407,6 +424,7 @@ internal sealed class ApiTimelineEvent {
value class Membership(val value: String) {
fun isJoin() = value == "join"
fun isInvite() = value == "invite"
fun isLeave() = value == "leave"
}
}

View File

@ -14,6 +14,8 @@ class RoomDataSource(
private val roomCache = mutableMapOf<RoomId, RoomState>()
fun contains(roomId: RoomId) = roomCache.containsKey(roomId)
suspend fun read(roomId: RoomId) = when (val cached = roomCache[roomId]) {
null -> roomStore.retrieve(roomId)?.also { roomCache[roomId] = it }
else -> cached
@ -27,4 +29,9 @@ class RoomDataSource(
roomStore.persist(roomId, newState)
}
}
suspend fun remove(roomsLeft: List<RoomId>) {
roomsLeft.forEach { roomCache.remove(it) }
roomStore.remove(roomsLeft)
}
}

View File

@ -4,30 +4,35 @@ import app.dapk.st.core.CoroutineDispatchers
import app.dapk.st.core.withIoContextAsync
import app.dapk.st.matrix.common.*
import app.dapk.st.matrix.common.MatrixLogTag.SYNC
import app.dapk.st.matrix.sync.InviteMeta
import app.dapk.st.matrix.sync.RoomInvite
import app.dapk.st.matrix.sync.RoomState
import app.dapk.st.matrix.sync.internal.request.ApiAccountEvent
import app.dapk.st.matrix.sync.internal.request.ApiSyncResponse
import app.dapk.st.matrix.sync.internal.request.ApiSyncRoom
import app.dapk.st.matrix.sync.internal.request.*
import app.dapk.st.matrix.sync.internal.room.SideEffectResult
import kotlinx.coroutines.awaitAll
internal class SyncReducer(
private val roomProcessor: RoomProcessor,
private val roomRefresher: RoomRefresher,
private val roomDataSource: RoomDataSource,
private val logger: MatrixLogger,
private val coroutineDispatchers: CoroutineDispatchers,
) {
data class ReducerResult(
val newRoomsJoined: List<RoomId>,
val roomState: List<RoomState>,
val invites: List<RoomInvite>
val invites: List<RoomInvite>,
val roomsLeft: List<RoomId>
)
suspend fun reduce(isInitialSync: Boolean, sideEffects: SideEffectResult, response: ApiSyncResponse, userCredentials: UserCredentials): ReducerResult {
val directMessages = response.directMessages()
val invites = response.rooms?.invite?.keys?.map { RoomInvite(it) } ?: emptyList()
val invites = response.rooms?.invite?.map { roomInvite(it, userCredentials) } ?: emptyList()
val roomsLeft = findRoomsLeft(response, userCredentials)
val newRooms = response.rooms?.join?.keys?.filterNot { roomDataSource.contains(it) } ?: emptyList()
val apiUpdatedRooms = response.rooms?.join?.keepRoomsWithChanges()
val apiRoomsToProcess = apiUpdatedRooms?.map { (roomId, apiRoom) ->
logger.matrixLog(SYNC, "reducing: $roomId")
@ -50,7 +55,34 @@ internal class SyncReducer(
}
}
return ReducerResult((apiRoomsToProcess + roomsWithSideEffects).awaitAll().filterNotNull(), invites)
roomDataSource.remove(roomsLeft)
return ReducerResult(
newRooms,
(apiRoomsToProcess + roomsWithSideEffects).awaitAll().filterNotNull(),
invites,
roomsLeft
)
}
private fun findRoomsLeft(response: ApiSyncResponse, userCredentials: UserCredentials) = response.rooms?.leave?.filter {
it.value.state.stateEvents.filterIsInstance<ApiTimelineEvent.RoomMember>().any {
it.content.membership.isLeave() && it.senderId == userCredentials.userId
}
}?.map { it.key } ?: emptyList()
private fun roomInvite(entry: Map.Entry<RoomId, ApiSyncRoomInvite>, userCredentials: UserCredentials): RoomInvite {
val memberEvents = entry.value.state.events.filterIsInstance<ApiStrippedEvent.RoomMember>()
val invitee = memberEvents.first { it.content.membership?.isInvite() ?: false }
val from = memberEvents.first { it.sender == invitee.sender }
return RoomInvite(
RoomMember(from.sender, from.content.displayName, from.content.avatarUrl?.convertMxUrToUrl(userCredentials.homeServer)?.let { AvatarUrl(it) }),
roomId = entry.key,
inviteMeta = when (invitee.content.isDirect) {
true -> InviteMeta.DirectMessage
null, false -> InviteMeta.Room(entry.value.state.events.filterIsInstance<ApiStrippedEvent.RoomName>().firstOrNull()?.content?.name)
},
)
}
}

View File

@ -46,9 +46,15 @@ internal class SyncUseCase(
val nextState = logger.logP("reducing") { syncReducer.reduce(isInitialSync, sideEffects, response, credentials) }
val overview = nextState.roomState.map { it.roomOverview }
if (nextState.roomsLeft.isNotEmpty()) {
persistence.removeRooms(nextState.roomsLeft)
}
if (nextState.invites.isNotEmpty()) {
persistence.persistInvites(nextState.invites)
}
if (nextState.newRoomsJoined.isNotEmpty()) {
persistence.removeInvites(nextState.newRoomsJoined)
}
when {
previousState == overview -> previousState.also { logger.matrixLog(SYNC, "no changes, not persisting new state") }