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 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 { val notificationsModule by unsafeLazy {
NotificationsModule( NotificationsModule(
matrixModules.push, matrixModules.push,

View File

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

View File

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

View File

@ -11,10 +11,8 @@ import app.dapk.st.matrix.sync.RoomInvite
import app.dapk.st.matrix.sync.RoomOverview import app.dapk.st.matrix.sync.RoomOverview
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
private val json = Json private val json = Json
@ -31,11 +29,22 @@ internal class OverviewPersistence(
.map { it.map { json.decodeFromString(RoomOverview.serializer(), it.blob) } } .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>) { override suspend fun persistInvites(invites: List<RoomInvite>) {
dispatchers.withIoContext { dispatchers.withIoContext {
database.inviteStateQueries.transaction { database.inviteStateQueries.transaction {
invites.forEach { 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() return database.inviteStateQueries.selectAll()
.asFlow() .asFlow()
.mapToList() .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) { override suspend fun persist(overviewState: OverviewState) {
@ -59,7 +76,7 @@ internal class OverviewPersistence(
} }
override suspend fun retrieve(): OverviewState { override suspend fun retrieve(): OverviewState {
return withContext(Dispatchers.IO) { return dispatchers.withIoContext {
val overviews = database.overviewStateQueries.selectAll().executeAsList() val overviews = database.overviewStateQueries.selectAll().executeAsList()
overviews.map { json.decodeFromString(RoomOverview.serializer(), it.blob) } 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> { override fun latest(roomId: RoomId): Flow<RoomState> {
val overviewFlow = database.overviewStateQueries.selectRoom(roomId.value).asFlow().mapToOneNotNull().map { val overviewFlow = database.overviewStateQueries.selectRoom(roomId.value).asFlow().mapToOneNotNull().map {
json.decodeFromString(RoomOverview.serializer(), it) json.decodeFromString(RoomOverview.serializer(), it)

View File

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

View File

@ -18,4 +18,8 @@ WHERE room_id = ?;
insert: insert:
INSERT OR REPLACE INTO dbOverviewState(room_id, latest_activity_timestamp_utc, read_marker, blob) 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: selectAllUnread:
SELECT dbRoomEvent.blob, dbRoomEvent.room_id SELECT dbRoomEvent.blob, dbRoomEvent.room_id
FROM dbUnreadEvent 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 package app.dapk.st.home
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -38,8 +37,9 @@ fun HomeScreen(homeViewModel: HomeViewModel) {
when (state.page) { when (state.page) {
Directory -> DirectoryScreen(homeViewModel.directory()) Directory -> DirectoryScreen(homeViewModel.directory())
Profile -> { 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, 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) } removedRooms.forEach { notificationManager.cancel(it.value, MESSAGE_NOTIFICATION_ID) }
val notifications = notificationFactory.createNotifications(result) val notifications = notificationFactory.createNotifications(result)
@ -26,7 +26,11 @@ class NotificationRenderer(
notifications.delegates.forEach { notifications.delegates.forEach {
when (it) { when (it) {
is NotificationDelegate.DismissRoom -> notificationManager.cancel(it.roomId.value, MESSAGE_NOTIFICATION_ID) 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.AppLogTag.NOTIFICATION
import app.dapk.st.core.log import app.dapk.st.core.log
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.RoomStore import app.dapk.st.matrix.sync.RoomStore
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.drop
@ -14,7 +15,7 @@ class NotificationsUseCase(
notificationChannels: NotificationChannels, notificationChannels: NotificationChannels,
) { ) {
private val inferredCurrentNotifications = mutableSetOf<RoomId>() private val inferredCurrentNotifications = mutableMapOf<RoomId, List<RoomEvent>>()
init { init {
notificationChannels.initChannels() notificationChannels.initChannels()
@ -26,13 +27,17 @@ class NotificationsUseCase(
.onEach { result -> .onEach { result ->
log(NOTIFICATION, "unread changed - render notifications") log(NOTIFICATION, "unread changed - render notifications")
val asRooms = result.keys.map { it.roomId }.toSet() val changes = result.mapKeys { it.key.roomId }
val removedRooms = inferredCurrentNotifications - asRooms
val asRooms = changes.keys
val removedRooms = inferredCurrentNotifications.keys - asRooms
val onlyContainsRemovals =
inferredCurrentNotifications.filterKeys { !removedRooms.contains(it) } == changes.filterKeys { !removedRooms.contains(it) }
inferredCurrentNotifications.clear() inferredCurrentNotifications.clear()
inferredCurrentNotifications.addAll(asRooms) inferredCurrentNotifications.putAll(changes)
notificationRenderer.render(result, removedRooms) notificationRenderer.render(result, removedRooms, onlyContainsRemovals)
} }
.collect() .collect()
} }

View File

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

View File

@ -2,6 +2,7 @@ applyAndroidLibraryModule(project)
dependencies { dependencies {
implementation project(":matrix:services:sync") implementation project(":matrix:services:sync")
implementation project(":matrix:services:room")
implementation project(":matrix:services:profile") implementation project(":matrix:services:profile")
implementation project(":features:settings") implementation project(":features:settings")
implementation project(':domains:store') 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.core.ProvidableModule
import app.dapk.st.matrix.room.ProfileService 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.matrix.sync.SyncService
class ProfileModule( class ProfileModule(
private val profileService: ProfileService, private val profileService: ProfileService,
private val syncService: SyncService, private val syncService: SyncService,
private val roomService: RoomService,
) : ProvidableModule { ) : ProvidableModule {
fun profileViewModel(): ProfileViewModel { fun profileViewModel(): ProfileViewModel {
return ProfileViewModel(profileService, syncService) return ProfileViewModel(profileService, syncService, roomService)
} }
} }

View File

@ -1,12 +1,13 @@
package app.dapk.st.profile package app.dapk.st.profile
import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* 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.foundation.shape.CircleShape
import androidx.compose.material.Icon import androidx.compose.material.*
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CameraAlt import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material.icons.filled.Settings 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.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import app.dapk.st.core.Lce
import app.dapk.st.core.LifecycleEffect import app.dapk.st.core.LifecycleEffect
import app.dapk.st.core.StartObserving import app.dapk.st.core.StartObserving
import app.dapk.st.core.components.CenteredLoading import app.dapk.st.core.components.CenteredLoading
import app.dapk.st.design.components.CircleishAvatar import app.dapk.st.design.components.*
import app.dapk.st.design.components.Spider import app.dapk.st.matrix.sync.InviteMeta
import app.dapk.st.design.components.TextRow import app.dapk.st.matrix.sync.RoomInvite
import app.dapk.st.design.components.percentOfHeight
import app.dapk.st.settings.SettingsActivity import app.dapk.st.settings.SettingsActivity
@Composable @Composable
fun ProfileScreen(viewModel: ProfileViewModel) { fun ProfileScreen(viewModel: ProfileViewModel, onTopLevelBack: () -> Unit) {
viewModel.ObserveEvents() viewModel.ObserveEvents()
LifecycleEffect(onStart = { LifecycleEffect(
viewModel.start() onStart = { viewModel.start() },
}) onStop = { viewModel.stop() }
)
val context = LocalContext.current 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( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -45,20 +65,20 @@ fun ProfileScreen(viewModel: ProfileViewModel) {
} }
} }
when (val state = viewModel.state) { when (val state = profile.content) {
ProfileScreenState.Loading -> CenteredLoading() is Lce.Loading -> CenteredLoading()
is ProfileScreenState.Content -> { is Lce.Content -> {
val configuration = LocalConfiguration.current val configuration = LocalConfiguration.current
val content = state.value
Column { Column {
Spacer(modifier = Modifier.fillMaxHeight(0.05f)) Spacer(modifier = Modifier.fillMaxHeight(0.05f))
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { 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) val avatarSize = configuration.percentOfHeight(0.2f)
Box { 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) { if (false) {
IconButton(modifier = Modifier IconButton(modifier = Modifier
.size(avatarSize * 0.314f) .size(avatarSize * 0.314f)
@ -76,26 +96,61 @@ fun ProfileScreen(viewModel: ProfileViewModel) {
TextRow( TextRow(
title = "Display name", title = "Display name",
content = state.me.displayName ?: "Not set", content = content.me.displayName ?: "Not set",
) )
TextRow( TextRow(
title = "User id", title = "User id",
content = state.me.userId.value, content = content.me.userId.value,
) )
TextRow( TextRow(
title = "Homeserver", title = "Homeserver",
content = state.me.homeServerUrl.value, content = content.me.homeServerUrl.value,
) )
TextRow( TextRow(
title = "Invitations", 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 @Composable
private fun ProfileViewModel.ObserveEvents() { private fun ProfileViewModel.ObserveEvents() {
val context = LocalContext.current val context = LocalContext.current

View File

@ -1,14 +1,30 @@
package app.dapk.st.profile 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.room.ProfileService
import app.dapk.st.matrix.sync.RoomInvite
sealed interface ProfileScreenState { data class ProfileScreenState(
object Loading : ProfileScreenState val page: SpiderPage<out Page>,
data class Content( )
val me: ProfileService.Me,
val invitationsCount: Int,
) : ProfileScreenState
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 { sealed interface ProfileEvent {

View File

@ -1,27 +1,74 @@
package app.dapk.st.profile package app.dapk.st.profile
import androidx.lifecycle.viewModelScope 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.ProfileService
import app.dapk.st.matrix.room.RoomService
import app.dapk.st.matrix.sync.SyncService import app.dapk.st.matrix.sync.SyncService
import app.dapk.st.viewmodel.DapkViewModel import app.dapk.st.viewmodel.DapkViewModel
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class ProfileViewModel( class ProfileViewModel(
private val profileService: ProfileService, private val profileService: ProfileService,
private val syncService: SyncService, private val syncService: SyncService,
private val roomService: RoomService,
) : DapkViewModel<ProfileScreenState, ProfileEvent>( ) : 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() { fun start() {
viewModelScope.launch { goToProfile()
val invitationsCount = syncService.invites().firstOrNull()?.size ?: 0 }
val me = profileService.me(forceRefresh = true)
state = ProfileScreenState.Content(me, invitationsCount = invitationsCount) 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() { fun updateDisplayName() {
// TODO // TODO
@ -31,4 +78,40 @@ class ProfileViewModel(
// TODO // 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 @Composable
private fun Encryption(viewModel: SettingsViewModel, page: Page.Security) { private fun Encryption(viewModel: SettingsViewModel, page: Page.Security) {
Column { Column {
TextRow("Import room keys", includeDivider = false) { TextRow("Import room keys", includeDivider = false, onClick = { viewModel.goToImportRoom() })
viewModel.goToImportRoom()
}
} }
} }

View File

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

View File

@ -62,6 +62,10 @@ class DefaultRoomService(
override suspend fun joinRoom(roomId: RoomId) { override suspend fun joinRoom(roomId: RoomId) {
httpClient.execute(joinRoomRequest(roomId)) httpClient.execute(joinRoomRequest(roomId))
} }
override suspend fun rejectJoinRoom(roomId: RoomId) {
httpClient.execute(rejectJoinRoomRequest(roomId))
}
} }
internal fun joinedMembersRequest(roomId: RoomId) = httpRequest<JoinedMembersResponse>( internal fun joinedMembersRequest(roomId: RoomId) = httpRequest<JoinedMembersResponse>(
@ -87,6 +91,13 @@ internal fun joinRoomRequest(roomId: RoomId) = httpRequest<Unit>(
body = emptyJsonBody() 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") @Suppress("EnumEntryName")
@Serializable @Serializable
enum class RoomVisibility { enum class RoomVisibility {

View File

@ -31,5 +31,18 @@ data class LastMessage(
@Serializable @Serializable
data class RoomInvite( data class RoomInvite(
@SerialName("from") val from: RoomMember,
@SerialName("room_id") val roomId: RoomId, @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 { interface RoomStore {
suspend fun persist(roomId: RoomId, state: RoomState) suspend fun persist(roomId: RoomId, state: RoomState)
suspend fun remove(rooms: List<RoomId>)
suspend fun retrieve(roomId: RoomId): RoomState? suspend fun retrieve(roomId: RoomId): RoomState?
fun latest(roomId: RoomId): Flow<RoomState> fun latest(roomId: RoomId): Flow<RoomState>
suspend fun insertUnread(roomId: RoomId, eventIds: List<EventId>) suspend fun insertUnread(roomId: RoomId, eventIds: List<EventId>)
@ -28,6 +29,7 @@ interface FilterStore {
interface OverviewStore { interface OverviewStore {
suspend fun removeRooms(roomsToRemove: List<RoomId>)
suspend fun persistInvites(invite: List<RoomInvite>) suspend fun persistInvites(invite: List<RoomInvite>)
suspend fun persist(overviewState: OverviewState) suspend fun persist(overviewState: OverviewState)
@ -35,6 +37,7 @@ interface OverviewStore {
fun latest(): Flow<OverviewState> fun latest(): Flow<OverviewState>
fun latestInvites(): Flow<List<RoomInvite>> fun latestInvites(): Flow<List<RoomInvite>>
suspend fun removeInvites(map: List<RoomId>)
} }
interface SyncStore { interface SyncStore {

View File

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

View File

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

View File

@ -214,6 +214,7 @@ sealed class ApiToDeviceEvent {
internal data class ApiSyncRooms( internal data class ApiSyncRooms(
@SerialName("join") val join: Map<RoomId, ApiSyncRoom>? = null, @SerialName("join") val join: Map<RoomId, ApiSyncRoom>? = null,
@SerialName("invite") val invite: Map<RoomId, ApiSyncRoomInvite>? = null, @SerialName("invite") val invite: Map<RoomId, ApiSyncRoomInvite>? = null,
@SerialName("leave") val leave: Map<RoomId, ApiSyncRoom>? = null,
) )
@Serializable @Serializable
@ -230,14 +231,30 @@ internal data class ApiInviteEvents(
sealed class ApiStrippedEvent { sealed class ApiStrippedEvent {
@Serializable @Serializable
@SerialName("m.room.create") @SerialName("m.room.member")
internal data class RoomCreate( 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, @SerialName("content") val content: Content,
) : ApiStrippedEvent() { ) : ApiStrippedEvent() {
@Serializable @Serializable
internal data class Content( 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) { value class Membership(val value: String) {
fun isJoin() = value == "join" fun isJoin() = value == "join"
fun isInvite() = value == "invite" fun isInvite() = value == "invite"
fun isLeave() = value == "leave"
} }
} }

View File

@ -14,6 +14,8 @@ class RoomDataSource(
private val roomCache = mutableMapOf<RoomId, RoomState>() private val roomCache = mutableMapOf<RoomId, RoomState>()
fun contains(roomId: RoomId) = roomCache.containsKey(roomId)
suspend fun read(roomId: RoomId) = when (val cached = roomCache[roomId]) { suspend fun read(roomId: RoomId) = when (val cached = roomCache[roomId]) {
null -> roomStore.retrieve(roomId)?.also { roomCache[roomId] = it } null -> roomStore.retrieve(roomId)?.also { roomCache[roomId] = it }
else -> cached else -> cached
@ -27,4 +29,9 @@ class RoomDataSource(
roomStore.persist(roomId, newState) 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.core.withIoContextAsync
import app.dapk.st.matrix.common.* import app.dapk.st.matrix.common.*
import app.dapk.st.matrix.common.MatrixLogTag.SYNC 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.RoomInvite
import app.dapk.st.matrix.sync.RoomState import app.dapk.st.matrix.sync.RoomState
import app.dapk.st.matrix.sync.internal.request.ApiAccountEvent import app.dapk.st.matrix.sync.internal.request.*
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.room.SideEffectResult import app.dapk.st.matrix.sync.internal.room.SideEffectResult
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
internal class SyncReducer( internal class SyncReducer(
private val roomProcessor: RoomProcessor, private val roomProcessor: RoomProcessor,
private val roomRefresher: RoomRefresher, private val roomRefresher: RoomRefresher,
private val roomDataSource: RoomDataSource,
private val logger: MatrixLogger, private val logger: MatrixLogger,
private val coroutineDispatchers: CoroutineDispatchers, private val coroutineDispatchers: CoroutineDispatchers,
) { ) {
data class ReducerResult( data class ReducerResult(
val newRoomsJoined: List<RoomId>,
val roomState: List<RoomState>, 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 { suspend fun reduce(isInitialSync: Boolean, sideEffects: SideEffectResult, response: ApiSyncResponse, userCredentials: UserCredentials): ReducerResult {
val directMessages = response.directMessages() 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 apiUpdatedRooms = response.rooms?.join?.keepRoomsWithChanges()
val apiRoomsToProcess = apiUpdatedRooms?.map { (roomId, apiRoom) -> val apiRoomsToProcess = apiUpdatedRooms?.map { (roomId, apiRoom) ->
logger.matrixLog(SYNC, "reducing: $roomId") 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 nextState = logger.logP("reducing") { syncReducer.reduce(isInitialSync, sideEffects, response, credentials) }
val overview = nextState.roomState.map { it.roomOverview } val overview = nextState.roomState.map { it.roomOverview }
if (nextState.roomsLeft.isNotEmpty()) {
persistence.removeRooms(nextState.roomsLeft)
}
if (nextState.invites.isNotEmpty()) { if (nextState.invites.isNotEmpty()) {
persistence.persistInvites(nextState.invites) persistence.persistInvites(nextState.invites)
} }
if (nextState.newRoomsJoined.isNotEmpty()) {
persistence.removeInvites(nextState.newRoomsJoined)
}
when { when {
previousState == overview -> previousState.also { logger.matrixLog(SYNC, "no changes, not persisting new state") } previousState == overview -> previousState.also { logger.matrixLog(SYNC, "no changes, not persisting new state") }