diff --git a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt index 72443f0..dd4f8a4 100644 --- a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt +++ b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt @@ -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, diff --git a/design-library/src/main/kotlin/app/dapk/st/design/components/Spider.kt b/design-library/src/main/kotlin/app/dapk/st/design/components/Spider.kt index 89753c5..826694b 100644 --- a/design-library/src/main/kotlin/app/dapk/st/design/components/Spider.kt +++ b/design-library/src/main/kotlin/app/dapk/st/design/components/Spider.kt @@ -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) } } diff --git a/design-library/src/main/kotlin/app/dapk/st/design/components/itemRow.kt b/design-library/src/main/kotlin/app/dapk/st/design/components/itemRow.kt index d76316e..6fdc9fa 100644 --- a/design-library/src/main/kotlin/app/dapk/st/design/components/itemRow.kt +++ b/design-library/src/main/kotlin/app/dapk/st/design/components/itemRow.kt @@ -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) { diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/sync/OverviewPersistence.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/sync/OverviewPersistence.kt index 7c6a9c6..b9bb5f8 100644 --- a/domains/store/src/main/kotlin/app/dapk/st/domain/sync/OverviewPersistence.kt +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/sync/OverviewPersistence.kt @@ -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) } } diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/sync/RoomPersistence.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/sync/RoomPersistence.kt index 24f06d4..451effd 100644 --- a/domains/store/src/main/kotlin/app/dapk/st/domain/sync/RoomPersistence.kt +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/sync/RoomPersistence.kt @@ -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) diff --git a/domains/store/src/main/sqldelight/app/dapk/db/model/InviteState.sq b/domains/store/src/main/sqldelight/app/dapk/db/model/InviteState.sq index d29ad00..d30ddbe 100644 --- a/domains/store/src/main/sqldelight/app/dapk/db/model/InviteState.sq +++ b/domains/store/src/main/sqldelight/app/dapk/db/model/InviteState.sq @@ -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 (?); \ No newline at end of file +INSERT OR REPLACE INTO dbInviteState(room_id, blob) +VALUES (?, ?); + +remove: +DELETE FROM dbInviteState +WHERE room_id = ?; \ No newline at end of file diff --git a/domains/store/src/main/sqldelight/app/dapk/db/model/OverviewState.sq b/domains/store/src/main/sqldelight/app/dapk/db/model/OverviewState.sq index afc9da1..100d397 100644 --- a/domains/store/src/main/sqldelight/app/dapk/db/model/OverviewState.sq +++ b/domains/store/src/main/sqldelight/app/dapk/db/model/OverviewState.sq @@ -18,4 +18,8 @@ WHERE room_id = ?; insert: INSERT OR REPLACE INTO dbOverviewState(room_id, latest_activity_timestamp_utc, read_marker, blob) -VALUES (?, ?, ?, ?); \ No newline at end of file +VALUES (?, ?, ?, ?); + +remove: +DELETE FROM dbOverviewState +WHERE room_id = ?; \ No newline at end of file diff --git a/domains/store/src/main/sqldelight/app/dapk/db/model/RoomEvent.sq b/domains/store/src/main/sqldelight/app/dapk/db/model/RoomEvent.sq index ce5a04b..46883cf 100644 --- a/domains/store/src/main/sqldelight/app/dapk/db/model/RoomEvent.sq +++ b/domains/store/src/main/sqldelight/app/dapk/db/model/RoomEvent.sq @@ -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 = ?; \ No newline at end of file diff --git a/features/home/src/main/kotlin/app/dapk/st/home/HomeScreen.kt b/features/home/src/main/kotlin/app/dapk/st/home/HomeScreen.kt index ec85869..62b7605 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/HomeScreen.kt +++ b/features/home/src/main/kotlin/app/dapk/st/home/HomeScreen.kt @@ -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) + } } } } diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationRenderer.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationRenderer.kt index f9fb12f..8ca8992 100644 --- a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationRenderer.kt +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationRenderer.kt @@ -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) + } + } } } diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationsUseCase.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationsUseCase.kt index 82b5219..87cd027 100644 --- a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationsUseCase.kt +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationsUseCase.kt @@ -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() } diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/PushAndroidService.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/PushAndroidService.kt index 8b845c7..5c6a0c9 100644 --- a/features/notifications/src/main/kotlin/app/dapk/st/notifications/PushAndroidService.kt +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/PushAndroidService.kt @@ -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") } } diff --git a/features/profile/build.gradle b/features/profile/build.gradle index efb3058..1e02634 100644 --- a/features/profile/build.gradle +++ b/features/profile/build.gradle @@ -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') diff --git a/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileModule.kt b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileModule.kt index cc83b7b..0fb5111 100644 --- a/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileModule.kt +++ b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileModule.kt @@ -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) } } \ No newline at end of file diff --git a/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileScreen.kt b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileScreen.kt index 5587329..e3a0f0d 100644 --- a/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileScreen.kt +++ b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileScreen.kt @@ -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 diff --git a/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileState.kt b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileState.kt index ab5acac..5e0b6e2 100644 --- a/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileState.kt +++ b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileState.kt @@ -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 { diff --git a/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileViewModel.kt b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileViewModel.kt index 3bb17b2..484cdcf 100644 --- a/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileViewModel.kt +++ b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileViewModel.kt @@ -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 = {}) +} \ No newline at end of file diff --git a/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsScreen.kt b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsScreen.kt index 6495835..9b3b793 100644 --- a/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsScreen.kt +++ b/features/settings/src/main/kotlin/app/dapk/st/settings/SettingsScreen.kt @@ -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() }) } } diff --git a/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/RoomService.kt b/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/RoomService.kt index 0f4164b..b5eb838 100644 --- a/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/RoomService.kt +++ b/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/RoomService.kt @@ -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, diff --git a/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/internal/DefaultRoomService.kt b/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/internal/DefaultRoomService.kt index 686d9b2..977a233 100644 --- a/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/internal/DefaultRoomService.kt +++ b/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/internal/DefaultRoomService.kt @@ -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 { diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/OverviewState.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/OverviewState.kt index b914799..75bf8ad 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/OverviewState.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/OverviewState.kt @@ -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() +} diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/Store.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/Store.kt index 97ddf63..de59886 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/Store.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/Store.kt @@ -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 { diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/SyncService.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/SyncService.kt index 725d13a..7fc345c 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/SyncService.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/SyncService.kt @@ -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> diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/DefaultSyncService.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/DefaultSyncService.kt index eff24ac..8a12cef 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/DefaultSyncService.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/DefaultSyncService.kt @@ -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 diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/ApiSyncResponse.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/ApiSyncResponse.kt index f068675..b125924 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/ApiSyncResponse.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/request/ApiSyncResponse.kt @@ -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" } } diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomDataSource.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomDataSource.kt index d58026b..df4bb80 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomDataSource.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomDataSource.kt @@ -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) + } } \ No newline at end of file diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/SyncReducer.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/SyncReducer.kt index 24eebc9..aa422ce 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/SyncReducer.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/SyncReducer.kt @@ -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) + }, + ) } } diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/SyncUseCase.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/SyncUseCase.kt index d56e8a8..5e840f4 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/SyncUseCase.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/SyncUseCase.kt @@ -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") }