diff --git a/composeApp/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/di/SharedModule.kt b/composeApp/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/di/SharedModule.kt index 6eb7a32a1..190b1713b 100644 --- a/composeApp/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/di/SharedModule.kt +++ b/composeApp/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/di/SharedModule.kt @@ -23,6 +23,7 @@ internal val sharedModule = settingsRepository = get(), entryCache = get(), eventCache = get(), + circleCache = get(), ) } single { diff --git a/composeApp/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/navigation/DefaultDetailOpener.kt b/composeApp/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/navigation/DefaultDetailOpener.kt index b822ba56c..a402d5d19 100644 --- a/composeApp/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/navigation/DefaultDetailOpener.kt +++ b/composeApp/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/navigation/DefaultDetailOpener.kt @@ -5,6 +5,7 @@ import com.livefast.eattrash.feature.userdetail.forum.ForumListScreen import com.livefast.eattrash.raccoonforfriendica.core.commonui.content.WebViewScreen import com.livefast.eattrash.raccoonforfriendica.core.navigation.DetailOpener import com.livefast.eattrash.raccoonforfriendica.core.navigation.NavigationCoordinator +import com.livefast.eattrash.raccoonforfriendica.domain.content.data.CircleModel import com.livefast.eattrash.raccoonforfriendica.domain.content.data.EventModel import com.livefast.eattrash.raccoonforfriendica.domain.content.data.FavoritesType import com.livefast.eattrash.raccoonforfriendica.domain.content.data.TimelineEntryModel @@ -17,8 +18,9 @@ import com.livefast.eattrash.raccoonforfriendica.domain.identity.repository.Iden import com.livefast.eattrash.raccoonforfriendica.domain.identity.repository.SettingsRepository import com.livefast.eattrash.raccoonforfriendica.feature.calendar.detail.EventDetailScreen import com.livefast.eattrash.raccoonforfriendica.feature.calendar.list.CalendarScreen -import com.livefast.eattrash.raccoonforfriendica.feature.circles.detail.CircleDetailScreen +import com.livefast.eattrash.raccoonforfriendica.feature.circles.editmembers.CircleMembersScreen import com.livefast.eattrash.raccoonforfriendica.feature.circles.list.CirclesScreen +import com.livefast.eattrash.raccoonforfriendica.feature.circles.timeline.CircleTimelineScreen import com.livefast.eattrash.raccoonforfriendica.feature.composer.ComposerScreen import com.livefast.eattrash.raccoonforfriendica.feature.directmessages.detail.ConversationScreen import com.livefast.eattrash.raccoonforfriendica.feature.directmessages.list.DirectMessageListScreen @@ -55,6 +57,7 @@ class DefaultDetailOpener( private val userCache: LocalItemCache, private val entryCache: LocalItemCache, private val eventCache: LocalItemCache, + private val circleCache: LocalItemCache, dispatcher: CoroutineDispatcher = Dispatchers.IO, ) : DetailOpener { private val currentUserId: String? get() = identityRepository.currentUser.value?.id @@ -116,7 +119,8 @@ class DefaultDetailOpener( override fun openFollowers( user: UserModel, - enableExport: Boolean) { + enableExport: Boolean, + ) { scope.launch { userCache.put(user.id, user) val screen = @@ -131,7 +135,8 @@ class DefaultDetailOpener( override fun openFollowing( user: UserModel, - enableExport: Boolean) { + enableExport: Boolean, + ) { scope.launch { userCache.put(user.id, user) val screen = @@ -285,11 +290,19 @@ class DefaultDetailOpener( navigationCoordinator.push(screen) } - override fun openCircle(groupId: String) { - val screen = CircleDetailScreen(groupId) + override fun openCircleEditMembers(groupId: String) { + val screen = CircleMembersScreen(groupId) navigationCoordinator.push(screen) } + override fun openCircleTimeline(circle: CircleModel) { + scope.launch { + circleCache.put(circle.id, circle) + val screen = CircleTimelineScreen(circle.id) + navigationCoordinator.push(screen) + } + } + override fun openFollowRequests() { val screen = FollowRequestsScreen() navigationCoordinator.push(screen) diff --git a/composeApp/src/commonTest/kotlin/com/livefast/eattrash/raccoonforfriendica/navigation/DefaultDetailOpenerTest.kt b/composeApp/src/commonTest/kotlin/com/livefast/eattrash/raccoonforfriendica/navigation/DefaultDetailOpenerTest.kt index 667e78a77..475a59a71 100644 --- a/composeApp/src/commonTest/kotlin/com/livefast/eattrash/raccoonforfriendica/navigation/DefaultDetailOpenerTest.kt +++ b/composeApp/src/commonTest/kotlin/com/livefast/eattrash/raccoonforfriendica/navigation/DefaultDetailOpenerTest.kt @@ -4,6 +4,7 @@ import com.livefast.eattrash.feature.userdetail.classic.UserDetailScreen import com.livefast.eattrash.feature.userdetail.forum.ForumListScreen import com.livefast.eattrash.raccoonforfriendica.core.commonui.content.WebViewScreen import com.livefast.eattrash.raccoonforfriendica.core.navigation.NavigationCoordinator +import com.livefast.eattrash.raccoonforfriendica.domain.content.data.CircleModel import com.livefast.eattrash.raccoonforfriendica.domain.content.data.EventModel import com.livefast.eattrash.raccoonforfriendica.domain.content.data.TimelineEntryModel import com.livefast.eattrash.raccoonforfriendica.domain.content.data.UnpublishedType @@ -14,8 +15,9 @@ import com.livefast.eattrash.raccoonforfriendica.domain.identity.repository.Iden import com.livefast.eattrash.raccoonforfriendica.domain.identity.repository.SettingsRepository import com.livefast.eattrash.raccoonforfriendica.feature.calendar.detail.EventDetailScreen import com.livefast.eattrash.raccoonforfriendica.feature.calendar.list.CalendarScreen -import com.livefast.eattrash.raccoonforfriendica.feature.circles.detail.CircleDetailScreen +import com.livefast.eattrash.raccoonforfriendica.feature.circles.editmembers.CircleMembersScreen import com.livefast.eattrash.raccoonforfriendica.feature.circles.list.CirclesScreen +import com.livefast.eattrash.raccoonforfriendica.feature.circles.timeline.CircleTimelineScreen import com.livefast.eattrash.raccoonforfriendica.feature.composer.ComposerScreen import com.livefast.eattrash.raccoonforfriendica.feature.directmessages.detail.ConversationScreen import com.livefast.eattrash.raccoonforfriendica.feature.directmessages.list.DirectMessageListScreen @@ -65,6 +67,7 @@ class DefaultDetailOpenerTest { private val userCache = mock>(mode = MockMode.autoUnit) private val entryCache = mock>(mode = MockMode.autoUnit) private val eventCache = mock>(mode = MockMode.autoUnit) + private val circleCache = mock>(mode = MockMode.autoUnit) private val sut = DefaultDetailOpener( navigationCoordinator = navigationCoordinator, @@ -73,6 +76,7 @@ class DefaultDetailOpenerTest { userCache = userCache, entryCache = entryCache, eventCache = eventCache, + circleCache = circleCache, dispatcher = UnconfinedTestDispatcher(), ) @@ -316,11 +320,22 @@ class DefaultDetailOpenerTest { } @Test - fun `when openCircle then interactions are as expected`() { - sut.openCircle(groupId = "1") + fun `when openCircleEditMembers then interactions are as expected`() { + sut.openCircleEditMembers(groupId = "1") verify { - navigationCoordinator.push(any()) + navigationCoordinator.push(any()) + } + } + + @Test + fun `when openCircleTimeline then interactions are as expected`() { + val circle = CircleModel(id = "id") + sut.openCircleTimeline(circle) + + verifySuspend { + circleCache.put(circle.id, circle) + navigationCoordinator.push(any()) } } diff --git a/core/api/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/api/service/UserService.kt b/core/api/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/api/service/UserService.kt index 86fd60e7e..b8d6a3581 100644 --- a/core/api/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/api/service/UserService.kt +++ b/core/api/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/api/service/UserService.kt @@ -31,6 +31,7 @@ interface UserService { @Query("q") query: String = "", @Query("offset") offset: Int = 0, @Query("resolve") resolve: Boolean = false, + @Query("following") following: Boolean = false, ): List @GET("v1/accounts/{id}/statuses") diff --git a/core/l10n/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/l10n/messages/DeStrings.kt b/core/l10n/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/l10n/messages/DeStrings.kt index b6871d4da..d45edd183 100644 --- a/core/l10n/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/l10n/messages/DeStrings.kt +++ b/core/l10n/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/l10n/messages/DeStrings.kt @@ -455,4 +455,5 @@ internal val DeStrings = override val actionChangeMarkupMode = "Auszeichnungsart ändern" override val confirmChangeMarkupMode = "Wenn Sie den Markup-Typ ändern, gehen alle Formatierungen verloren. Trotzdem weitermachen?" + override val actionEditMembers = "Mitglieder bearbeiten" } diff --git a/core/l10n/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/l10n/messages/DefaultStrings.kt b/core/l10n/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/l10n/messages/DefaultStrings.kt index 71978eee8..6fa09fb2f 100644 --- a/core/l10n/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/l10n/messages/DefaultStrings.kt +++ b/core/l10n/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/l10n/messages/DefaultStrings.kt @@ -450,4 +450,5 @@ internal open class DefaultStrings : Strings { override val actionChangeMarkupMode = "Change markup type" override val confirmChangeMarkupMode = "If you change the markup type, all the formatting will be lost. Proceed anyway?" + override val actionEditMembers = "Edit members" } diff --git a/core/l10n/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/l10n/messages/EsStrings.kt b/core/l10n/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/l10n/messages/EsStrings.kt index 822629851..e9ad42a6f 100644 --- a/core/l10n/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/l10n/messages/EsStrings.kt +++ b/core/l10n/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/l10n/messages/EsStrings.kt @@ -455,4 +455,5 @@ internal val EsStrings = override val actionChangeMarkupMode = "Cambiar tipo de marcado" override val confirmChangeMarkupMode = "Si cambia el tipo de marcado, se perderá todo el formato. ¿Proceder de todos modos?" + override val actionEditMembers = "Editar miembros" } diff --git a/core/l10n/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/l10n/messages/FrStrings.kt b/core/l10n/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/l10n/messages/FrStrings.kt index 0ae0babc2..16b445265 100644 --- a/core/l10n/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/l10n/messages/FrStrings.kt +++ b/core/l10n/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/l10n/messages/FrStrings.kt @@ -460,4 +460,5 @@ internal val FrStrings = override val actionChangeMarkupMode = "Modifier type de balisage" override val confirmChangeMarkupMode = "Si vous modifiez le type de balisage, toutes les mises en forme seront perdues. Poursuivre quand même ?" + override val actionEditMembers = "Modifier les membres" } diff --git a/core/l10n/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/l10n/messages/ItStrings.kt b/core/l10n/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/l10n/messages/ItStrings.kt index 3ee4f1473..565d973b8 100644 --- a/core/l10n/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/l10n/messages/ItStrings.kt +++ b/core/l10n/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/l10n/messages/ItStrings.kt @@ -455,4 +455,5 @@ internal val ItStrings = override val actionChangeMarkupMode = "Cambia il tipo di markup" override val confirmChangeMarkupMode = "Se si cambia il tipo di markup, tutta la formattazione andrà persa. Procedere comunque?" + override val actionEditMembers = "Modifica membri" } diff --git a/core/l10n/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/l10n/messages/PlStrings.kt b/core/l10n/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/l10n/messages/PlStrings.kt index 08cb25795..0a43d742c 100644 --- a/core/l10n/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/l10n/messages/PlStrings.kt +++ b/core/l10n/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/l10n/messages/PlStrings.kt @@ -453,4 +453,5 @@ internal val PlStrings = override val actionChangeMarkupMode = "Zmiana typu znaczników" override val confirmChangeMarkupMode = "Jeśli zmienisz typ znaczników, całe formatowanie zostanie utracone. Kontynuować mimo to?" + override val actionEditMembers = "Edytuj członków" } diff --git a/core/l10n/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/l10n/messages/PtStrings.kt b/core/l10n/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/l10n/messages/PtStrings.kt index 3a907909d..06e9c9726 100644 --- a/core/l10n/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/l10n/messages/PtStrings.kt +++ b/core/l10n/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/l10n/messages/PtStrings.kt @@ -459,4 +459,5 @@ internal val PtStrings = override val actionChangeMarkupMode = "Alterar tipo de marcação" override val confirmChangeMarkupMode = "Se alterar o tipo de marcação, toda a formatação se perderá. Continuar na mesma?" + override val actionEditMembers = "Editar membros" } diff --git a/core/l10n/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/l10n/messages/Strings.kt b/core/l10n/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/l10n/messages/Strings.kt index 01508bf8e..562fbf563 100644 --- a/core/l10n/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/l10n/messages/Strings.kt +++ b/core/l10n/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/l10n/messages/Strings.kt @@ -401,6 +401,7 @@ interface Strings { val actionExport: String val actionChangeMarkupMode: String val confirmChangeMarkupMode: String + val actionEditMembers: String } object Locales { diff --git a/core/navigation/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/navigation/DetailOpener.kt b/core/navigation/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/navigation/DetailOpener.kt index 1f62ebc80..43084c3e3 100644 --- a/core/navigation/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/navigation/DetailOpener.kt +++ b/core/navigation/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/navigation/DetailOpener.kt @@ -1,6 +1,7 @@ package com.livefast.eattrash.raccoonforfriendica.core.navigation import androidx.compose.runtime.Stable +import com.livefast.eattrash.raccoonforfriendica.domain.content.data.CircleModel import com.livefast.eattrash.raccoonforfriendica.domain.content.data.EventModel import com.livefast.eattrash.raccoonforfriendica.domain.content.data.TimelineEntryModel import com.livefast.eattrash.raccoonforfriendica.domain.content.data.UnpublishedType @@ -30,7 +31,8 @@ interface DetailOpener { fun openFollowing( user: UserModel, - enableExport: Boolean = false) + enableExport: Boolean = false, + ) fun openFavorites() @@ -77,7 +79,9 @@ interface DetailOpener { fun openCircles() - fun openCircle(groupId: String) + fun openCircleEditMembers(groupId: String) + + fun openCircleTimeline(circle: CircleModel) fun openFollowRequests() diff --git a/core/notifications/src/androidMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/notifications/di/Utils.kt b/core/notifications/src/androidMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/notifications/di/Utils.kt new file mode 100644 index 000000000..adfc18bf1 --- /dev/null +++ b/core/notifications/src/androidMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/notifications/di/Utils.kt @@ -0,0 +1,9 @@ +package com.livefast.eattrash.raccoonforfriendica.core.notifications.di + +import com.livefast.eattrash.raccoonforfriendica.core.notifications.NotificationCenter +import org.koin.java.KoinJavaComponent + +actual fun getNotificationCenter(): NotificationCenter { + val res by KoinJavaComponent.inject(NotificationCenter::class.java) + return res +} diff --git a/core/notifications/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/notifications/di/Utils.kt b/core/notifications/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/notifications/di/Utils.kt new file mode 100644 index 000000000..2620db7c9 --- /dev/null +++ b/core/notifications/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/notifications/di/Utils.kt @@ -0,0 +1,5 @@ +package com.livefast.eattrash.raccoonforfriendica.core.notifications.di + +import com.livefast.eattrash.raccoonforfriendica.core.notifications.NotificationCenter + +expect fun getNotificationCenter(): NotificationCenter diff --git a/core/notifications/src/iosMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/notifications/di/Utils.kt b/core/notifications/src/iosMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/notifications/di/Utils.kt new file mode 100644 index 000000000..e42d1d9d6 --- /dev/null +++ b/core/notifications/src/iosMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/notifications/di/Utils.kt @@ -0,0 +1,11 @@ +package com.livefast.eattrash.raccoonforfriendica.core.notifications.di + +import com.livefast.eattrash.raccoonforfriendica.core.notifications.NotificationCenter +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +actual fun getNotificationCenter(): NotificationCenter = CoreNotificationsDiHelper.notificationCenter + +internal object CoreNotificationsDiHelper : KoinComponent { + val notificationCenter by inject() +} diff --git a/docs/manual/it/main.md b/docs/manual/it/main.md index a669962df..0c313609e 100644 --- a/docs/manual/it/main.md +++ b/docs/manual/it/main.md @@ -837,16 +837,22 @@ Al fine di rendere la consultazione più facile, Raccoon suddivide in sezioni la un'intestazione di sezione che specifica il tipo dei contenuti sottostanti. Tra queste tre categorie, l'unica che consente di essere modificata è la prima, per la quale è -possibile: +possibile utilizzare il pulsante "⋮" per: -- utilizzare il pulsante "⋮" per modificare il nome o eliminarle; -- entrare nella schermata di dettaglio cerchia, visualizzare i contatti che ne fanno parte e - aggiungerne di nuovi (con il pulsante "+") o rimuovere quelli esistenti. +- modificare il nome; +- eliminare la cerchia; +- visualizzare i contatti che ne fanno parte e aggiungerne di nuovi (con il pulsante "+") o + rimuovere quelli esistenti. -Ricorda che in Friendica, come impostazione predefinita, tutti i contatti che non sono di tipo -gruppo vengono aggiunti alla cerchia "Amici" mentre tutti i contatti di tipo gruppo vengono aggiunti -alla cerchia "Gruppi". Pur essendo create in automatico dal sistema, "Amici" e "Gruppi" sono -cerchie normalissime che possono essere modificate o eliminate. +Va tenuto presente che in Friendica, come impostazione predefinita, tutti i contatti che non sono di +tipo gruppo vengono aggiunti alla cerchia "Amici" mentre tutti i contatti di tipo gruppo vengono +aggiunti alla cerchia "Gruppi". Pur essendo create in automatico dal sistema, "Amici" e "Gruppi" +sono cerchie normalissime che possono essere modificate o eliminate. + +Facendo tap su ogni voce della lista cerchie, verrà aperta la lista dei post corrispondenti, ovvero: + +- la [modalità forum](#modalità-forum) per i gruppi; +- una timeline dedicata per tutte le altre cerchie.
circle list screen diff --git a/docs/manual/main.md b/docs/manual/main.md index 17bc76230..6cff41361 100644 --- a/docs/manual/main.md +++ b/docs/manual/main.md @@ -776,16 +776,22 @@ This screens contains all the custom feeds that can be used in the timeline, whi To make it easier to browse and understand this list, Raccoon divides the list in sections, each with its header specifying the type of the following items. -Among these three categories, the only one which allow to be modified is the first one, for which: +Among these three categories, the only one which allow to be modified is the first one, for which +you can use the "⋮" button to: -- you can use the "⋮" button to edit the name or delete it; -- you can enter a circle detail screen to see the contacts that belong to it and add new ones (with - the "+" button) or remove existing ones. +- edit the circle name; +- delete the circle; +- see the circle members and add new ones (with the "+" button) or remove existing ones. Please remember that in Friendica by default all non-group contacts are added to the "Friends" circle and all group contacts are added to "Group". Albeit being created by the system, "Friends" and "Groups" are regulars circles that can be changed or deleted. +By tapping on each item in the circle list, you will open the corresponding post list, i.e.: + +- the [forum mode](#forum-mode) for groups; +- a dedicated timeline, for all other circles. +
circle list screen circle detail screen diff --git a/domain/content/repository/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/domain/content/repository/DefaultUserRepository.kt b/domain/content/repository/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/domain/content/repository/DefaultUserRepository.kt index f80241720..3268fa8a0 100644 --- a/domain/content/repository/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/domain/content/repository/DefaultUserRepository.kt +++ b/domain/content/repository/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/domain/content/repository/DefaultUserRepository.kt @@ -38,6 +38,7 @@ internal class DefaultUserRepository( override suspend fun search( query: String, offset: Int, + following: Boolean, ): List? = withContext(Dispatchers.IO) { runCatching { @@ -47,6 +48,7 @@ internal class DefaultUserRepository( query = query, offset = offset, resolve = true, + following = following, ).map { it.toModel() } } }.getOrNull() diff --git a/domain/content/repository/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/domain/content/repository/UserRepository.kt b/domain/content/repository/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/domain/content/repository/UserRepository.kt index d2ea354be..e774ac30e 100644 --- a/domain/content/repository/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/domain/content/repository/UserRepository.kt +++ b/domain/content/repository/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/domain/content/repository/UserRepository.kt @@ -10,6 +10,7 @@ interface UserRepository { suspend fun search( query: String, offset: Int, + following: Boolean = false, ): List? suspend fun getByHandle(handle: String): UserModel? diff --git a/domain/content/repository/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/domain/content/repository/di/ContentRepositoryModule.kt b/domain/content/repository/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/domain/content/repository/di/ContentRepositoryModule.kt index 5e6356ac0..eebda5503 100644 --- a/domain/content/repository/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/domain/content/repository/di/ContentRepositoryModule.kt +++ b/domain/content/repository/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/domain/content/repository/di/ContentRepositoryModule.kt @@ -1,5 +1,6 @@ package com.livefast.eattrash.raccoonforfriendica.domain.content.repository.di +import com.livefast.eattrash.raccoonforfriendica.domain.content.data.CircleModel import com.livefast.eattrash.raccoonforfriendica.domain.content.data.EventModel import com.livefast.eattrash.raccoonforfriendica.domain.content.data.TimelineEntryModel import com.livefast.eattrash.raccoonforfriendica.domain.content.data.UserModel @@ -133,6 +134,9 @@ val domainContentRepositoryModule = single> { DefaultLocalItemCache() } + single> { + DefaultLocalItemCache() + } single { DefaultScheduledEntryRepository( provider = get(named("default")), diff --git a/feature/circles/build.gradle.kts b/feature/circles/build.gradle.kts index 584fdfbfa..498e18e35 100644 --- a/feature/circles/build.gradle.kts +++ b/feature/circles/build.gradle.kts @@ -46,6 +46,7 @@ kotlin { implementation(projects.core.commonui.content) implementation(projects.core.l10n) implementation(projects.core.navigation) + implementation(projects.core.notifications) implementation(projects.core.utils) implementation(projects.domain.content.data) @@ -53,6 +54,7 @@ kotlin { implementation(projects.domain.content.repository) implementation(projects.domain.identity.data) implementation(projects.domain.identity.repository) + implementation(projects.domain.identity.usecase) } } } diff --git a/feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/di/CirclesModule.kt b/feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/di/CirclesModule.kt index 4bc91f0f9..775032967 100644 --- a/feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/di/CirclesModule.kt +++ b/feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/di/CirclesModule.kt @@ -1,9 +1,11 @@ package com.livefast.eattrash.raccoonforfriendica.feature.circles.di -import com.livefast.eattrash.raccoonforfriendica.feature.circles.detail.CircleDetailMviModel -import com.livefast.eattrash.raccoonforfriendica.feature.circles.detail.CircleDetailViewModel +import com.livefast.eattrash.raccoonforfriendica.feature.circles.editmembers.CircleMembersMviModel +import com.livefast.eattrash.raccoonforfriendica.feature.circles.editmembers.CircleMembersViewModel import com.livefast.eattrash.raccoonforfriendica.feature.circles.list.CirclesMviModel import com.livefast.eattrash.raccoonforfriendica.feature.circles.list.CirclesViewModel +import com.livefast.eattrash.raccoonforfriendica.feature.circles.timeline.CircleTimelineMviModel +import com.livefast.eattrash.raccoonforfriendica.feature.circles.timeline.CircleTimelineViewModel import org.koin.dsl.module val featureCirclesModule = @@ -12,10 +14,11 @@ val featureCirclesModule = CirclesViewModel( circlesRepository = get(), settingsRepository = get(), + userRepository = get(), ) } - factory { params -> - CircleDetailViewModel( + factory { params -> + CircleMembersViewModel( id = params[0], paginationManager = get(), circlesRepository = get(), @@ -25,4 +28,20 @@ val featureCirclesModule = imageAutoloadObserver = get(), ) } + factory { param -> + CircleTimelineViewModel( + id = param[0], + paginationManager = get(), + identityRepository = get(), + timelineEntryRepository = get(), + settingsRepository = get(), + circleCache = get(), + userRepository = get(), + hapticFeedback = get(), + notificationCenter = get(), + imagePreloadManager = get(), + blurHashRepository = get(), + imageAutoloadObserver = get(), + ) + } } diff --git a/feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/detail/CircleDetailMviModel.kt b/feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/editmembers/CircleMembersMviModel.kt similarity index 90% rename from feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/detail/CircleDetailMviModel.kt rename to feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/editmembers/CircleMembersMviModel.kt index 870fdd109..93ffc3ff5 100644 --- a/feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/detail/CircleDetailMviModel.kt +++ b/feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/editmembers/CircleMembersMviModel.kt @@ -1,13 +1,13 @@ -package com.livefast.eattrash.raccoonforfriendica.feature.circles.detail +package com.livefast.eattrash.raccoonforfriendica.feature.circles.editmembers import cafe.adriel.voyager.core.model.ScreenModel import com.livefast.eattrash.raccoonforfriendica.core.architecture.MviModel import com.livefast.eattrash.raccoonforfriendica.domain.content.data.CircleModel import com.livefast.eattrash.raccoonforfriendica.domain.content.data.UserModel -interface CircleDetailMviModel : +interface CircleMembersMviModel : ScreenModel, - MviModel { + MviModel { sealed interface Intent { data object Refresh : Intent diff --git a/feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/detail/CircleDetailScreen.kt b/feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/editmembers/CircleMembersScreen.kt similarity index 93% rename from feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/detail/CircleDetailScreen.kt rename to feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/editmembers/CircleMembersScreen.kt index 6c30ef1a0..2e480a4ee 100644 --- a/feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/detail/CircleDetailScreen.kt +++ b/feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/editmembers/CircleMembersScreen.kt @@ -1,4 +1,4 @@ -package com.livefast.eattrash.raccoonforfriendica.feature.circles.detail +package com.livefast.eattrash.raccoonforfriendica.feature.circles.editmembers import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.slideInVertically @@ -62,7 +62,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import org.koin.core.parameter.parametersOf -class CircleDetailScreen( +class CircleMembersScreen( val id: String, ) : Screen { override val key: ScreenKey @@ -71,7 +71,7 @@ class CircleDetailScreen( @OptIn(ExperimentalMaterial3Api::class) @Composable override fun Content() { - val model = getScreenModel(parameters = { parametersOf(id) }) + val model = getScreenModel(parameters = { parametersOf(id) }) val uiState by model.uiState.collectAsState() val topAppBarState = rememberTopAppBarState() val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(topAppBarState) @@ -99,7 +99,7 @@ class CircleDetailScreen( model.effects .onEach { event -> when (event) { - CircleDetailMviModel.Effect.Failure -> + CircleMembersMviModel.Effect.Failure -> snackbarHostState.showSnackbar(genericError) } }.launchIn(this) @@ -147,7 +147,7 @@ class CircleDetailScreen( ) { FloatingActionButton( onClick = { - model.reduce(CircleDetailMviModel.Intent.ToggleAddUsersDialog(true)) + model.reduce(CircleMembersMviModel.Intent.ToggleAddUsersDialog(true)) }, ) { Icon( @@ -183,7 +183,7 @@ class CircleDetailScreen( ).nestedScroll(fabNestedScrollConnection), isRefreshing = uiState.refreshing, onRefresh = { - model.reduce(CircleDetailMviModel.Intent.Refresh) + model.reduce(CircleMembersMviModel.Intent.Refresh) }, ) { LazyColumn( @@ -258,7 +258,7 @@ class CircleDetailScreen( val userId = confirmRemoveUserId confirmRemoveUserId = null if (confirm && userId != null) { - model.reduce(CircleDetailMviModel.Intent.Remove(userId)) + model.reduce(CircleMembersMviModel.Intent.Remove(userId)) } }, ) @@ -272,15 +272,15 @@ class CircleDetailScreen( loading = uiState.userSearchLoading, canFetchMore = uiState.userSearchCanFetchMore, onLoadMoreUsers = { - model.reduce(CircleDetailMviModel.Intent.UserSearchLoadNextPage) + model.reduce(CircleMembersMviModel.Intent.UserSearchLoadNextPage) }, onSearchChanged = { - model.reduce(CircleDetailMviModel.Intent.SetSearchUserQuery(it)) + model.reduce(CircleMembersMviModel.Intent.SetSearchUserQuery(it)) }, onClose = { values -> - model.reduce(CircleDetailMviModel.Intent.ToggleAddUsersDialog(false)) + model.reduce(CircleMembersMviModel.Intent.ToggleAddUsersDialog(false)) if (values != null) { - model.reduce(CircleDetailMviModel.Intent.Add(values)) + model.reduce(CircleMembersMviModel.Intent.Add(values)) } }, ) diff --git a/feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/detail/CircleDetailViewModel.kt b/feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/editmembers/CircleMembersViewModel.kt similarity index 89% rename from feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/detail/CircleDetailViewModel.kt rename to feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/editmembers/CircleMembersViewModel.kt index 7f7a59535..ce5f34e9e 100644 --- a/feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/detail/CircleDetailViewModel.kt +++ b/feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/editmembers/CircleMembersViewModel.kt @@ -1,4 +1,4 @@ -package com.livefast.eattrash.raccoonforfriendica.feature.circles.detail +package com.livefast.eattrash.raccoonforfriendica.feature.circles.editmembers import cafe.adriel.voyager.core.model.screenModelScope import com.livefast.eattrash.raccoonforfriendica.core.architecture.DefaultMviModel @@ -20,7 +20,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.yield @OptIn(FlowPreview::class) -class CircleDetailViewModel( +class CircleMembersViewModel( private val id: String, private val paginationManager: UserPaginationManager, private val circlesRepository: CirclesRepository, @@ -28,10 +28,10 @@ class CircleDetailViewModel( private val searchPaginationManager: UserPaginationManager, private val imagePreloadManager: ImagePreloadManager, private val imageAutoloadObserver: ImageAutoloadObserver, -) : DefaultMviModel( - initialState = CircleDetailMviModel.State(), +) : DefaultMviModel( + initialState = CircleMembersMviModel.State(), ), - CircleDetailMviModel { + CircleMembersMviModel { init { screenModelScope.launch { imageAutoloadObserver.enabled @@ -69,14 +69,14 @@ class CircleDetailViewModel( } } - override fun reduce(intent: CircleDetailMviModel.Intent) { + override fun reduce(intent: CircleMembersMviModel.Intent) { when (intent) { - CircleDetailMviModel.Intent.Refresh -> + CircleMembersMviModel.Intent.Refresh -> screenModelScope.launch { refresh() } - is CircleDetailMviModel.Intent.ToggleAddUsersDialog -> + is CircleMembersMviModel.Intent.ToggleAddUsersDialog -> screenModelScope.launch { if (intent.opened) { refreshSearchUsers("") @@ -92,18 +92,18 @@ class CircleDetailViewModel( } } - is CircleDetailMviModel.Intent.SetSearchUserQuery -> + is CircleMembersMviModel.Intent.SetSearchUserQuery -> screenModelScope.launch { updateState { it.copy(searchUsersQuery = intent.text) } } - CircleDetailMviModel.Intent.UserSearchLoadNextPage -> + CircleMembersMviModel.Intent.UserSearchLoadNextPage -> screenModelScope.launch { loadNextPageSearchUsers() } - is CircleDetailMviModel.Intent.Add -> add(intent.users) - is CircleDetailMviModel.Intent.Remove -> remove(intent.userId) + is CircleMembersMviModel.Intent.Add -> add(intent.users) + is CircleMembersMviModel.Intent.Remove -> remove(intent.userId) } } @@ -193,7 +193,7 @@ class CircleDetailViewModel( if (success) { insertItemsInState(users) } else { - emitEffect(CircleDetailMviModel.Effect.Failure) + emitEffect(CircleMembersMviModel.Effect.Failure) } } } @@ -204,7 +204,7 @@ class CircleDetailViewModel( if (success) { removeItemFromState(userId) } else { - emitEffect(CircleDetailMviModel.Effect.Failure) + emitEffect(CircleMembersMviModel.Effect.Failure) } } } diff --git a/feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/list/CirclesMviModel.kt b/feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/list/CirclesMviModel.kt index b436ae1d7..30d0cda9e 100644 --- a/feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/list/CirclesMviModel.kt +++ b/feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/list/CirclesMviModel.kt @@ -6,6 +6,7 @@ import com.livefast.eattrash.raccoonforfriendica.core.utils.validation.Validatio import com.livefast.eattrash.raccoonforfriendica.domain.content.data.CircleModel import com.livefast.eattrash.raccoonforfriendica.domain.content.data.CircleReplyPolicy import com.livefast.eattrash.raccoonforfriendica.domain.content.data.CircleType +import com.livefast.eattrash.raccoonforfriendica.domain.content.data.UserModel data class CircleEditorData( val id: String? = null, @@ -46,6 +47,10 @@ interface CirclesMviModel : data class Delete( val circleId: String, ) : Intent + + data class OpenDetail( + val circle: CircleModel, + ) : Intent } data class State( @@ -55,9 +60,18 @@ interface CirclesMviModel : val items: List = emptyList(), val editorData: CircleEditorData? = null, val hideNavigationBarWhileScrolling: Boolean = true, + val operationInProgress: Boolean = false, ) sealed interface Effect { data object Failure : Effect + + data class OpenUser( + val user: UserModel, + ) : Effect + + data class OpenCircle( + val circle: CircleModel, + ) : Effect } } diff --git a/feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/list/CirclesScreen.kt b/feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/list/CirclesScreen.kt index ff181de74..70ce60e9a 100644 --- a/feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/list/CirclesScreen.kt +++ b/feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/list/CirclesScreen.kt @@ -46,6 +46,7 @@ import cafe.adriel.voyager.koin.getScreenModel import com.livefast.eattrash.raccoonforfriendica.core.appearance.theme.Spacing import com.livefast.eattrash.raccoonforfriendica.core.appearance.theme.toWindowInsets import com.livefast.eattrash.raccoonforfriendica.core.commonui.components.ListLoadingIndicator +import com.livefast.eattrash.raccoonforfriendica.core.commonui.components.ProgressHud import com.livefast.eattrash.raccoonforfriendica.core.commonui.components.di.getFabNestedScrollConnection import com.livefast.eattrash.raccoonforfriendica.core.commonui.content.CustomConfirmDialog import com.livefast.eattrash.raccoonforfriendica.core.commonui.content.OptionId @@ -96,6 +97,11 @@ class CirclesScreen : Screen { when (event) { CirclesMviModel.Effect.Failure -> snackbarHostState.showSnackbar(genericError) + is CirclesMviModel.Effect.OpenUser -> + detailOpener.openUserDetail(event.user) + + is CirclesMviModel.Effect.OpenCircle -> + detailOpener.openCircleTimeline(event.circle) } }.launchIn(this) } @@ -211,15 +217,18 @@ class CirclesScreen : Screen { CircleItem( modifier = Modifier.padding(bottom = Spacing.interItem), circle = item.circle, - onClick = - { - detailOpener.openCircle(item.circle.id) - }.takeIf { item.circle.canBeEdited }, + onClick = { + model.reduce(CirclesMviModel.Intent.OpenDetail(item.circle)) + }, options = buildList { if (item.circle.canBeEdited) { this += OptionId.Edit.toOption() this += OptionId.Delete.toOption() + this += + CustomOptions.EditMembers.toOption( + label = LocalStrings.current.actionEditMembers, + ) } }, onOptionSelected = { optionId -> @@ -227,6 +236,9 @@ class CirclesScreen : Screen { OptionId.Edit -> { model.reduce(CirclesMviModel.Intent.OpenEditor(item.circle)) } + CustomOptions.EditMembers -> { + detailOpener.openCircleEditMembers(item.circle.id) + } OptionId.Delete -> confirmDeleteItemId = item.circle.id else -> Unit @@ -265,6 +277,10 @@ class CirclesScreen : Screen { } } + if (uiState.operationInProgress) { + ProgressHud() + } + if (confirmDeleteItemId != null) { CustomConfirmDialog( title = LocalStrings.current.actionDelete, @@ -296,3 +312,7 @@ class CirclesScreen : Screen { } } } + +sealed interface CustomOptions : OptionId.Custom { + data object EditMembers : CustomOptions +} diff --git a/feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/list/CirclesViewModel.kt b/feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/list/CirclesViewModel.kt index e8229cac0..ff47fcf6f 100644 --- a/feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/list/CirclesViewModel.kt +++ b/feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/list/CirclesViewModel.kt @@ -7,6 +7,7 @@ import com.livefast.eattrash.raccoonforfriendica.domain.content.data.CircleModel import com.livefast.eattrash.raccoonforfriendica.domain.content.data.CircleReplyPolicy import com.livefast.eattrash.raccoonforfriendica.domain.content.data.CircleType import com.livefast.eattrash.raccoonforfriendica.domain.content.repository.CirclesRepository +import com.livefast.eattrash.raccoonforfriendica.domain.content.repository.UserRepository import com.livefast.eattrash.raccoonforfriendica.domain.identity.repository.SettingsRepository import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -15,6 +16,7 @@ import kotlinx.coroutines.launch class CirclesViewModel( private val circlesRepository: CirclesRepository, private val settingsRepository: SettingsRepository, + private val userRepository: UserRepository, ) : DefaultMviModel( initialState = CirclesMviModel.State(), ), @@ -68,6 +70,7 @@ class CirclesViewModel( CirclesMviModel.Intent.SubmitEditorData -> submitEditorData() is CirclesMviModel.Intent.Delete -> delete(intent.circleId) + is CirclesMviModel.Intent.OpenDetail -> handleOpenDetail(intent.circle) } } @@ -225,4 +228,25 @@ class CirclesViewModel( } } } + + private fun handleOpenDetail(circle: CircleModel) { + screenModelScope.launch { + if (circle.type == CircleType.Group) { + updateState { it.copy(operationInProgress = true) } + val user = + userRepository + .search( + query = circle.name, + following = true, + offset = 0, + )?.firstOrNull() + updateState { it.copy(operationInProgress = false) } + if (user != null) { + emitEffect(CirclesMviModel.Effect.OpenUser(user)) + } + } else { + emitEffect(CirclesMviModel.Effect.OpenCircle(circle)) + } + } + } } diff --git a/feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/timeline/CircleTimelineMviModel.kt b/feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/timeline/CircleTimelineMviModel.kt new file mode 100644 index 000000000..6001f8bf4 --- /dev/null +++ b/feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/timeline/CircleTimelineMviModel.kt @@ -0,0 +1,84 @@ +package com.livefast.eattrash.raccoonforfriendica.feature.circles.timeline + +import cafe.adriel.voyager.core.model.ScreenModel +import com.livefast.eattrash.raccoonforfriendica.core.architecture.MviModel +import com.livefast.eattrash.raccoonforfriendica.domain.content.data.CircleModel +import com.livefast.eattrash.raccoonforfriendica.domain.content.data.TimelineEntryModel +import com.livefast.eattrash.raccoonforfriendica.domain.content.data.TimelineType +import kotlin.time.Duration + +interface CircleTimelineMviModel : + ScreenModel, + MviModel { + sealed interface Intent { + data object Refresh : Intent + + data object LoadNextPage : Intent + + data class ToggleReblog( + val entry: TimelineEntryModel, + ) : Intent + + data class ToggleFavorite( + val entry: TimelineEntryModel, + ) : Intent + + data class ToggleBookmark( + val entry: TimelineEntryModel, + ) : Intent + + data class DeleteEntry( + val entryId: String, + ) : Intent + + data class MuteUser( + val userId: String, + val entryId: String, + val duration: Duration = Duration.INFINITE, + val disableNotifications: Boolean = true, + ) : Intent + + data class BlockUser( + val userId: String, + val entryId: String, + ) : Intent + + data class TogglePin( + val entry: TimelineEntryModel, + ) : Intent + + data class SubmitPollVote( + val entry: TimelineEntryModel, + val choices: List, + ) : Intent + + data class CopyToClipboard( + val entry: TimelineEntryModel, + ) : Intent + } + + data class State( + val circle: CircleModel? = null, + val currentUserId: String? = null, + val refreshing: Boolean = false, + val loading: Boolean = false, + val initial: Boolean = true, + val canFetchMore: Boolean = true, + val availableTimelineTypes: List = emptyList(), + val entries: List = emptyList(), + val blurNsfw: Boolean = true, + val maxBodyLines: Int = Int.MAX_VALUE, + val autoloadImages: Boolean = true, + val hideNavigationBarWhileScrolling: Boolean = true, + ) + + sealed interface Effect { + data object BackToTop : Effect + + data object PollVoteFailure : Effect + + data class TriggerCopy( + val text: String, + ) : Effect + } +} diff --git a/feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/timeline/CircleTimelineScreen.kt b/feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/timeline/CircleTimelineScreen.kt new file mode 100644 index 000000000..b2b7da032 --- /dev/null +++ b/feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/timeline/CircleTimelineScreen.kt @@ -0,0 +1,520 @@ +package com.livefast.eattrash.raccoonforfriendica.feature.circles.timeline + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.koin.getScreenModel +import com.livefast.eattrash.raccoonforfriendica.core.appearance.theme.Spacing +import com.livefast.eattrash.raccoonforfriendica.core.appearance.theme.toWindowInsets +import com.livefast.eattrash.raccoonforfriendica.core.commonui.components.ListLoadingIndicator +import com.livefast.eattrash.raccoonforfriendica.core.commonui.content.ConfirmMuteUserBottomSheet +import com.livefast.eattrash.raccoonforfriendica.core.commonui.content.CustomConfirmDialog +import com.livefast.eattrash.raccoonforfriendica.core.commonui.content.EntryDetailDialog +import com.livefast.eattrash.raccoonforfriendica.core.commonui.content.OptionId +import com.livefast.eattrash.raccoonforfriendica.core.commonui.content.PollVoteErrorDialog +import com.livefast.eattrash.raccoonforfriendica.core.commonui.content.TimelineItem +import com.livefast.eattrash.raccoonforfriendica.core.commonui.content.TimelineItemPlaceholder +import com.livefast.eattrash.raccoonforfriendica.core.commonui.content.toOption +import com.livefast.eattrash.raccoonforfriendica.core.l10n.messages.LocalStrings +import com.livefast.eattrash.raccoonforfriendica.core.navigation.di.getDetailOpener +import com.livefast.eattrash.raccoonforfriendica.core.navigation.di.getNavigationCoordinator +import com.livefast.eattrash.raccoonforfriendica.core.utils.datetime.getDurationFromDateToNow +import com.livefast.eattrash.raccoonforfriendica.core.utils.di.getShareHelper +import com.livefast.eattrash.raccoonforfriendica.core.utils.isNearTheEnd +import com.livefast.eattrash.raccoonforfriendica.domain.content.data.TimelineEntryModel +import com.livefast.eattrash.raccoonforfriendica.domain.content.data.isOldEntry +import com.livefast.eattrash.raccoonforfriendica.domain.content.data.original +import com.livefast.eattrash.raccoonforfriendica.domain.content.data.safeKey +import com.livefast.eattrash.raccoonforfriendica.domain.identity.usecase.di.getEntryActionRepository +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import org.koin.core.parameter.parametersOf +import kotlin.time.Duration + +class CircleTimelineScreen( + val id: String, +) : Screen { + @OptIn(ExperimentalMaterial3Api::class) + @Composable + override fun Content() { + val model = getScreenModel(parameters = { parametersOf(id) }) + val uiState by model.uiState.collectAsState() + val navigationCoordinator = remember { getNavigationCoordinator() } + val topAppBarState = rememberTopAppBarState() + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(topAppBarState) + val connection = navigationCoordinator.getBottomBarScrollConnection() + val uriHandler = LocalUriHandler.current + val detailOpener = remember { getDetailOpener() } + val scope = rememberCoroutineScope() + val lazyListState = rememberLazyListState() + val snackbarHostState = remember { SnackbarHostState() } + val shareHelper = remember { getShareHelper() } + val actionRepository = remember { getEntryActionRepository() } + val copyToClipboardSuccess = LocalStrings.current.messageTextCopiedToClipboard + val clipboardManager = LocalClipboardManager.current + var confirmDeleteEntryId by remember { mutableStateOf(null) } + var confirmMuteEntry by remember { mutableStateOf(null) } + var confirmBlockEntry by remember { mutableStateOf(null) } + var confirmReblogEntry by remember { mutableStateOf(null) } + var pollErrorDialogOpened by remember { mutableStateOf(false) } + var seeDetailsEntry by remember { mutableStateOf(null) } + + suspend fun goBackToTop() { + runCatching { + lazyListState.scrollToItem(0) + topAppBarState.heightOffset = 0f + topAppBarState.contentOffset = 0f + } + } + + LaunchedEffect(model) { + model.effects + .onEach { event -> + when (event) { + CircleTimelineMviModel.Effect.BackToTop -> goBackToTop() + CircleTimelineMviModel.Effect.PollVoteFailure -> + pollErrorDialogOpened = + true + + is CircleTimelineMviModel.Effect.TriggerCopy -> { + clipboardManager.setText(AnnotatedString(event.text)) + snackbarHostState.showSnackbar(copyToClipboardSuccess) + } + } + }.launchIn(this) + } + + Scaffold( + topBar = { + TopAppBar( + windowInsets = topAppBarState.toWindowInsets(), + modifier = + Modifier.clickable { + scope.launch { + goBackToTop() + } + }, + scrollBehavior = scrollBehavior, + title = { + Text( + text = uiState.circle?.name.orEmpty(), + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + navigationIcon = { + if (navigationCoordinator.canPop.value) { + IconButton( + onClick = { + navigationCoordinator.pop() + }, + ) { + Icon( + imageVector = Icons.AutoMirrored.Default.ArrowBack, + contentDescription = null, + ) + } + } + }, + ) + }, + snackbarHost = { + SnackbarHost( + hostState = snackbarHostState, + ) { data -> + Snackbar( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + snackbarData = data, + ) + } + }, + ) { padding -> + PullToRefreshBox( + modifier = + Modifier + .padding(padding) + .fillMaxWidth() + .then( + if (connection != null && uiState.hideNavigationBarWhileScrolling) { + Modifier.nestedScroll(connection) + } else { + Modifier + }, + ).then( + if (uiState.hideNavigationBarWhileScrolling) { + Modifier.nestedScroll(scrollBehavior.nestedScrollConnection) + } else { + Modifier + }, + ), + isRefreshing = uiState.refreshing, + onRefresh = { + model.reduce(CircleTimelineMviModel.Intent.Refresh) + }, + ) { + LazyColumn( + state = lazyListState, + ) { + if (uiState.initial) { + val placeholderCount = 5 + items(placeholderCount) { idx -> + TimelineItemPlaceholder(modifier = Modifier.fillMaxWidth()) + if (idx < placeholderCount - 1) { + HorizontalDivider( + modifier = Modifier.padding(vertical = Spacing.s), + ) + } + } + } + + if (!uiState.initial && !uiState.refreshing && !uiState.loading && uiState.entries.isEmpty()) { + item { + Text( + modifier = Modifier.fillMaxWidth().padding(top = Spacing.m), + text = LocalStrings.current.messageEmptyList, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge, + ) + } + } + + itemsIndexed( + items = uiState.entries, + key = { _, e -> "timeline-${e.safeKey}" }, + ) { idx, entry -> + TimelineItem( + entry = entry, + blurNsfw = uiState.blurNsfw, + autoloadImages = uiState.autoloadImages, + maxBodyLines = uiState.maxBodyLines, + onClick = { e -> + detailOpener.openEntryDetail(e) + }, + onOpenUrl = { url -> + uriHandler.openUri(url) + }, + onOpenUser = { + detailOpener.openUserDetail(it) + }, + onOpenImage = { urls, imageIdx, videoIndices -> + detailOpener.openImageDetail( + urls = urls, + initialIndex = imageIdx, + videoIndices = videoIndices, + ) + }, + onReblog = + { e: TimelineEntryModel -> + val timeSinceCreation = + e.created?.run { + getDurationFromDateToNow(this) + } ?: Duration.ZERO + when { + !e.reblogged && timeSinceCreation.isOldEntry -> + confirmReblogEntry = e + + else -> + model.reduce( + CircleTimelineMviModel.Intent.ToggleReblog(e), + ) + } + }.takeIf { actionRepository.canReblog(entry.original) }, + onBookmark = + { e: TimelineEntryModel -> + model.reduce(CircleTimelineMviModel.Intent.ToggleBookmark(e)) + }.takeIf { actionRepository.canBookmark(entry.original) }, + onFavorite = + { e: TimelineEntryModel -> + model.reduce(CircleTimelineMviModel.Intent.ToggleFavorite(e)) + }.takeIf { actionRepository.canReact(entry.original) }, + onReply = + { e: TimelineEntryModel -> + detailOpener.openComposer( + inReplyTo = e, + inReplyToUser = e.creator, + ) + }.takeIf { actionRepository.canReply(entry.original) }, + onPollVote = + uiState.currentUserId?.let { + { e, choices -> + model.reduce( + CircleTimelineMviModel.Intent.SubmitPollVote( + entry = e, + choices = choices, + ), + ) + } + }, + options = + buildList { + if (actionRepository.canShare(entry.original)) { + this += OptionId.Share.toOption() + this += OptionId.CopyUrl.toOption() + } + if (actionRepository.canEdit(entry.original)) { + this += OptionId.Edit.toOption() + } + if (actionRepository.canDelete(entry.original)) { + this += OptionId.Delete.toOption() + } + if (actionRepository.canTogglePin(entry)) { + if (entry.pinned) { + this += OptionId.Unpin.toOption() + } else { + this += OptionId.Pin.toOption() + } + } + if (actionRepository.canMute(entry)) { + this += OptionId.Mute.toOption() + } + if (actionRepository.canBlock(entry)) { + this += OptionId.Block.toOption() + } + if (actionRepository.canReport(entry.original)) { + this += OptionId.ReportUser.toOption() + this += OptionId.ReportEntry.toOption() + } + if (actionRepository.canQuote(entry.original)) { + this += OptionId.Quote.toOption() + } + this += OptionId.ViewDetails.toOption() + this += OptionId.CopyToClipboard.toOption() + }, + onOptionSelected = { optionId -> + when (optionId) { + OptionId.Share -> { + val urlString = entry.url.orEmpty() + shareHelper.share(urlString) + } + + OptionId.CopyUrl -> { + val urlString = entry.url.orEmpty() + clipboardManager.setText(AnnotatedString(urlString)) + scope.launch { + snackbarHostState.showSnackbar(copyToClipboardSuccess) + } + } + + OptionId.Edit -> { + entry.original.also { entryToEdit -> + detailOpener.openComposer( + inReplyTo = entryToEdit.inReplyTo, + inReplyToUser = entryToEdit.inReplyTo?.creator, + editedPostId = entryToEdit.id, + ) + } + } + + OptionId.Delete -> confirmDeleteEntryId = entry.id + OptionId.Mute -> confirmMuteEntry = entry + OptionId.Block -> confirmBlockEntry = entry + OptionId.Pin, OptionId.Unpin -> + model.reduce(CircleTimelineMviModel.Intent.TogglePin(entry)) + + OptionId.ReportUser -> + entry.original.creator?.also { userToReport -> + detailOpener.openCreateReport(user = userToReport) + } + + OptionId.ReportEntry -> + entry.original.also { entryToReport -> + entryToReport.creator?.also { userToReport -> + detailOpener.openCreateReport( + user = userToReport, + entry = entryToReport, + ) + } + } + + OptionId.Quote -> { + entry.original.also { entryToShare -> + detailOpener.openComposer( + urlToShare = entryToShare.url, + ) + } + } + + OptionId.ViewDetails -> seeDetailsEntry = entry.original + OptionId.CopyToClipboard -> + model.reduce( + CircleTimelineMviModel.Intent.CopyToClipboard( + entry.original, + ), + ) + + else -> Unit + } + }, + ) + if (idx < uiState.entries.lastIndex) { + HorizontalDivider( + modifier = Modifier.padding(vertical = Spacing.s), + ) + } + + val canFetchMore = + !uiState.initial && !uiState.loading && uiState.canFetchMore + val isNearTheEnd = idx.isNearTheEnd(uiState.entries) + if (isNearTheEnd && canFetchMore) { + model.reduce(CircleTimelineMviModel.Intent.LoadNextPage) + } + } + + item { + if (uiState.loading && !uiState.refreshing && uiState.canFetchMore) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + ListLoadingIndicator() + } + } + } + + item { + Spacer(modifier = Modifier.height(Spacing.xxxl)) + } + } + } + } + + if (confirmDeleteEntryId != null) { + CustomConfirmDialog( + title = LocalStrings.current.actionDelete, + onClose = { confirm -> + val entryId = confirmDeleteEntryId ?: "" + confirmDeleteEntryId = null + if (confirm && entryId.isNotEmpty()) { + model.reduce(CircleTimelineMviModel.Intent.DeleteEntry(entryId)) + } + }, + ) + } + + if (confirmMuteEntry != null) { + (confirmMuteEntry?.reblog?.creator ?: confirmMuteEntry?.creator)?.also { user -> + ConfirmMuteUserBottomSheet( + userHandle = user.handle.orEmpty(), + onClose = { pair -> + val entryId = confirmMuteEntry?.id + confirmMuteEntry = null + if (pair != null) { + val (duration, disableNotifications) = pair + if (entryId != null) { + model.reduce( + CircleTimelineMviModel.Intent.MuteUser( + userId = user.id, + entryId = entryId, + duration = duration, + disableNotifications = disableNotifications, + ), + ) + } + } + }, + ) + } + } + + if (confirmBlockEntry != null) { + val creator = confirmBlockEntry?.reblog?.creator ?: confirmBlockEntry?.creator + CustomConfirmDialog( + title = + buildString { + append(LocalStrings.current.actionBlock) + val handle = creator?.handle ?: "" + if (handle.isNotEmpty()) { + append(" @$handle") + } + }, + onClose = { confirm -> + val entryId = confirmBlockEntry?.id + confirmBlockEntry = null + val creatorId = creator?.id + confirmBlockEntry = null + if (confirm && entryId != null && creatorId != null) { + model.reduce( + CircleTimelineMviModel.Intent.BlockUser( + userId = creatorId, + entryId = entryId, + ), + ) + } + }, + ) + } + + if (pollErrorDialogOpened) { + PollVoteErrorDialog( + onDismissRequest = { + pollErrorDialogOpened = false + }, + ) + } + + if (confirmReblogEntry != null) { + CustomConfirmDialog( + title = LocalStrings.current.buttonConfirm, + body = LocalStrings.current.messageAreYouSureReblog, + onClose = { confirm -> + val e = confirmReblogEntry + confirmReblogEntry = null + if (confirm && e != null) { + model.reduce(CircleTimelineMviModel.Intent.ToggleReblog(e)) + } + }, + ) + } + + seeDetailsEntry?.let { entry -> + EntryDetailDialog( + entry = entry, + onClose = { + seeDetailsEntry = null + }, + ) + } + } +} diff --git a/feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/timeline/CircleTimelineViewModel.kt b/feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/timeline/CircleTimelineViewModel.kt new file mode 100644 index 000000000..490b5ee82 --- /dev/null +++ b/feature/circles/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/circles/timeline/CircleTimelineViewModel.kt @@ -0,0 +1,429 @@ +package com.livefast.eattrash.raccoonforfriendica.feature.circles.timeline + +import cafe.adriel.voyager.core.model.screenModelScope +import com.livefast.eattrash.raccoonforfriendica.core.architecture.DefaultMviModel +import com.livefast.eattrash.raccoonforfriendica.core.notifications.NotificationCenter +import com.livefast.eattrash.raccoonforfriendica.core.notifications.events.TimelineEntryDeletedEvent +import com.livefast.eattrash.raccoonforfriendica.core.notifications.events.TimelineEntryUpdatedEvent +import com.livefast.eattrash.raccoonforfriendica.core.utils.imageload.BlurHashRepository +import com.livefast.eattrash.raccoonforfriendica.core.utils.imageload.ImagePreloadManager +import com.livefast.eattrash.raccoonforfriendica.core.utils.vibrate.HapticFeedback +import com.livefast.eattrash.raccoonforfriendica.domain.content.data.CircleModel +import com.livefast.eattrash.raccoonforfriendica.domain.content.data.TimelineEntryModel +import com.livefast.eattrash.raccoonforfriendica.domain.content.data.TimelineType +import com.livefast.eattrash.raccoonforfriendica.domain.content.data.blurHashParamsForPreload +import com.livefast.eattrash.raccoonforfriendica.domain.content.data.original +import com.livefast.eattrash.raccoonforfriendica.domain.content.data.urlsForPreload +import com.livefast.eattrash.raccoonforfriendica.domain.content.pagination.TimelinePaginationManager +import com.livefast.eattrash.raccoonforfriendica.domain.content.pagination.TimelinePaginationSpecification +import com.livefast.eattrash.raccoonforfriendica.domain.content.repository.LocalItemCache +import com.livefast.eattrash.raccoonforfriendica.domain.content.repository.TimelineEntryRepository +import com.livefast.eattrash.raccoonforfriendica.domain.content.repository.UserRepository +import com.livefast.eattrash.raccoonforfriendica.domain.identity.data.SettingsModel +import com.livefast.eattrash.raccoonforfriendica.domain.identity.repository.IdentityRepository +import com.livefast.eattrash.raccoonforfriendica.domain.identity.repository.ImageAutoloadObserver +import com.livefast.eattrash.raccoonforfriendica.domain.identity.repository.SettingsRepository +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlin.time.Duration + +class CircleTimelineViewModel( + id: String, + private val paginationManager: TimelinePaginationManager, + private val identityRepository: IdentityRepository, + private val timelineEntryRepository: TimelineEntryRepository, + private val settingsRepository: SettingsRepository, + private val userRepository: UserRepository, + private val circleCache: LocalItemCache, + private val hapticFeedback: HapticFeedback, + private val notificationCenter: NotificationCenter, + private val imagePreloadManager: ImagePreloadManager, + private val blurHashRepository: BlurHashRepository, + private val imageAutoloadObserver: ImageAutoloadObserver, +) : DefaultMviModel( + initialState = CircleTimelineMviModel.State(), + ), + CircleTimelineMviModel { + init { + screenModelScope.launch { + val circle = circleCache.get(id) + updateState { it.copy(circle = circle) } + + settingsRepository.current + .onEach { settings -> + updateState { + it.copy( + blurNsfw = settings?.blurNsfw ?: true, + maxBodyLines = settings?.maxPostBodyLines ?: Int.MAX_VALUE, + hideNavigationBarWhileScrolling = + settings?.hideNavigationBarWhileScrolling ?: true, + ) + } + }.launchIn(this) + + imageAutoloadObserver.enabled + .onEach { autoloadImages -> + updateState { + it.copy( + autoloadImages = autoloadImages, + ) + } + }.launchIn(this) + + identityRepository.currentUser + .onEach { currentUser -> + updateState { it.copy(currentUserId = currentUser?.id) } + }.launchIn(this) + + notificationCenter + .subscribe(TimelineEntryUpdatedEvent::class) + .onEach { event -> + updateEntryInState(event.entry.id) { event.entry } + }.launchIn(this) + + if (uiState.value.initial) { + refresh( + initial = true, + forceRefresh = true, + ) + } + } + } + + override fun reduce(intent: CircleTimelineMviModel.Intent) { + when (intent) { + CircleTimelineMviModel.Intent.Refresh -> + screenModelScope.launch { + refresh() + } + + CircleTimelineMviModel.Intent.LoadNextPage -> + screenModelScope.launch { + loadNextPage() + } + + is CircleTimelineMviModel.Intent.ToggleReblog -> toggleReblog(intent.entry) + is CircleTimelineMviModel.Intent.ToggleFavorite -> toggleFavorite(intent.entry) + is CircleTimelineMviModel.Intent.ToggleBookmark -> toggleBookmark(intent.entry) + is CircleTimelineMviModel.Intent.DeleteEntry -> deleteEntry(intent.entryId) + is CircleTimelineMviModel.Intent.MuteUser -> + mute( + userId = intent.userId, + entryId = intent.entryId, + duration = intent.duration, + disableNotifications = intent.disableNotifications, + ) + + is CircleTimelineMviModel.Intent.BlockUser -> + block( + userId = intent.userId, + entryId = intent.entryId, + ) + + is CircleTimelineMviModel.Intent.TogglePin -> togglePin(intent.entry) + is CircleTimelineMviModel.Intent.SubmitPollVote -> + submitPoll( + intent.entry, + intent.choices, + ) + + is CircleTimelineMviModel.Intent.CopyToClipboard -> copyToClipboard(intent.entry) + } + } + + private suspend fun refresh( + initial: Boolean = false, + forceRefresh: Boolean = false, + ) { + val circle = uiState.value.circle ?: return + updateState { + it.copy(initial = initial, refreshing = !initial) + } + val settings = settingsRepository.current.value ?: SettingsModel() + paginationManager.reset( + TimelinePaginationSpecification.Feed( + timelineType = TimelineType.Circle(circle), + includeNsfw = settings.includeNsfw, + excludeReplies = settings.excludeRepliesFromTimeline, + refresh = forceRefresh || !initial, + ), + ) + loadNextPage() + } + + private suspend fun loadNextPage() { + if (uiState.value.loading) { + return + } + + val wasRefreshing = uiState.value.refreshing + updateState { it.copy(loading = true) } + val entries = paginationManager.loadNextPage() + entries.preloadImages() + updateState { + it.copy( + entries = entries, + canFetchMore = paginationManager.canFetchMore, + loading = false, + initial = false, + refreshing = false, + ) + } + if (wasRefreshing) { + emitEffect(CircleTimelineMviModel.Effect.BackToTop) + } + } + + private suspend fun List.preloadImages() { + flatMap { entry -> + entry.original.urlsForPreload + }.forEach { url -> + imagePreloadManager.preload(url) + } + flatMap { entry -> + entry.blurHashParamsForPreload + }.forEach { + blurHashRepository.preload(it) + } + } + + private suspend fun updateEntryInState( + entryId: String, + block: (TimelineEntryModel) -> TimelineEntryModel, + ) { + updateState { + it.copy( + entries = + it.entries.map { entry -> + when { + entry.id == entryId -> { + entry.let(block) + } + + entry.reblog?.id == entryId -> { + entry.copy(reblog = entry.reblog?.let(block)) + } + + else -> { + entry + } + } + }, + ) + } + } + + private suspend fun removeEntryFromState(entryId: String) { + updateState { + it.copy( + entries = it.entries.filter { e -> e.id != entryId && e.reblog?.id != entryId }, + ) + } + } + + private fun toggleReblog(entry: TimelineEntryModel) { + hapticFeedback.vibrate() + screenModelScope.launch { + updateEntryInState(entry.id) { + it.copy( + reblogLoading = true, + ) + } + val newEntry = + if (entry.reblogged) { + timelineEntryRepository.unreblog(entry.id) + } else { + timelineEntryRepository.reblog(entry.id) + } + if (newEntry != null) { + updateEntryInState(entry.id) { + it + .copy( + reblogged = newEntry.reblogged, + reblogCount = newEntry.reblogCount, + reblogLoading = false, + ).also { entry -> + notificationCenter.send(TimelineEntryUpdatedEvent(entry = entry)) + } + } + } else { + updateEntryInState(entry.id) { + it.copy( + reblogLoading = false, + ) + } + } + } + } + + private fun toggleFavorite(entry: TimelineEntryModel) { + hapticFeedback.vibrate() + screenModelScope.launch { + updateEntryInState(entry.id) { + it.copy( + favoriteLoading = true, + ) + } + val newEntry = + if (entry.favorite) { + timelineEntryRepository.unfavorite(entry.id) + } else { + timelineEntryRepository.favorite(entry.id) + } + if (newEntry != null) { + updateEntryInState(entry.id) { + it + .copy( + favorite = newEntry.favorite, + favoriteCount = newEntry.favoriteCount, + favoriteLoading = false, + ).also { entry -> + notificationCenter.send(TimelineEntryUpdatedEvent(entry = entry)) + } + } + } else { + updateEntryInState(entry.id) { + it.copy( + favoriteLoading = false, + ) + } + } + } + } + + private fun toggleBookmark(entry: TimelineEntryModel) { + hapticFeedback.vibrate() + screenModelScope.launch { + updateEntryInState(entry.id) { + it.copy( + bookmarkLoading = true, + ) + } + val newEntry = + if (entry.bookmarked) { + timelineEntryRepository.unbookmark(entry.id) + } else { + timelineEntryRepository.bookmark(entry.id) + } + if (newEntry != null) { + updateEntryInState(entry.id) { + it + .copy( + bookmarked = newEntry.bookmarked, + bookmarkLoading = false, + ).also { entry -> + notificationCenter.send(TimelineEntryUpdatedEvent(entry = entry)) + } + } + } else { + updateEntryInState(entry.id) { + it.copy( + bookmarkLoading = false, + ) + } + } + } + } + + private fun deleteEntry(entryId: String) { + screenModelScope.launch { + val success = timelineEntryRepository.delete(entryId) + if (success) { + notificationCenter.send(TimelineEntryDeletedEvent(entryId)) + removeEntryFromState(entryId) + } + } + } + + private fun mute( + userId: String, + entryId: String, + duration: Duration, + disableNotifications: Boolean, + ) { + screenModelScope.launch { + val res = + userRepository.mute( + id = userId, + durationSeconds = if (duration.isInfinite()) 0 else duration.inWholeSeconds, + notifications = disableNotifications, + ) + if (res != null) { + removeEntryFromState(entryId) + } + } + } + + private fun block( + userId: String, + entryId: String, + ) { + screenModelScope.launch { + val res = userRepository.block(userId) + if (res != null) { + removeEntryFromState(entryId) + } + } + } + + private fun togglePin(entry: TimelineEntryModel) { + screenModelScope.launch { + val newEntry = + if (entry.pinned) { + timelineEntryRepository.unpin(entry.id) + } else { + timelineEntryRepository.pin(entry.id) + } + if (newEntry != null) { + updateEntryInState(entry.id) { + it.copy( + pinned = newEntry.pinned, + ) + } + } + } + } + + private fun submitPoll( + entry: TimelineEntryModel, + choices: List, + ) { + val poll = entry.poll ?: return + screenModelScope.launch { + updateEntryInState(entry.id) { it.copy(poll = poll.copy(loading = true)) } + val newPoll = + timelineEntryRepository.submitPoll( + pollId = poll.id, + choices = choices, + ) + if (newPoll != null) { + updateEntryInState(entry.id) { + it.copy(poll = newPoll).also { entry -> + notificationCenter.send(TimelineEntryUpdatedEvent(entry = entry)) + } + } + } else { + updateEntryInState(entry.id) { it.copy(poll = poll.copy(loading = false)) } + emitEffect(CircleTimelineMviModel.Effect.PollVoteFailure) + } + } + } + + private fun copyToClipboard(entry: TimelineEntryModel) { + screenModelScope.launch { + val source = timelineEntryRepository.getSource(entry.id) + if (source != null) { + val text = + buildString { + if (!entry.title.isNullOrBlank()) { + append(entry.title) + append("\n") + } + append(source.content) + } + emitEffect(CircleTimelineMviModel.Effect.TriggerCopy(text)) + } + } + } +} diff --git a/feature/timeline/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/timeline/TimelineViewModel.kt b/feature/timeline/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/timeline/TimelineViewModel.kt index 78eb29e24..2f6561877 100644 --- a/feature/timeline/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/timeline/TimelineViewModel.kt +++ b/feature/timeline/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/timeline/TimelineViewModel.kt @@ -138,21 +138,7 @@ class TimelineViewModel( is TimelineMviModel.Intent.ChangeType -> screenModelScope.launch { - if (uiState.value.loading) { - return@launch - } - - updateState { - it.copy( - initial = true, - timelineType = intent.type, - ) - } - emitEffect(TimelineMviModel.Effect.BackToTop) - refresh( - initial = true, - forceRefresh = true, - ) + changeTimelineType(intent.type) } is TimelineMviModel.Intent.ToggleReblog -> toggleReblog(intent.entry) @@ -182,6 +168,24 @@ class TimelineViewModel( } } + private suspend fun changeTimelineType(type: TimelineType) { + if (uiState.value.loading) { + return + } + + updateState { + it.copy( + initial = true, + timelineType = type, + ) + } + emitEffect(TimelineMviModel.Effect.BackToTop) + refresh( + initial = true, + forceRefresh = true, + ) + } + private suspend fun refreshCirclesInTimelineTypes(isLogged: Boolean) { val circles = circlesRepository.getAll().orEmpty() val settings = settingsRepository.current.value ?: SettingsModel()