Yuito-app-android/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsViewModel.kt

210 lines
7.9 KiB
Kotlin
Raw Normal View History

/* Copyright 2022 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.viewthread.edits
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.getOrElse
import com.keylesspalace.tusky.components.viewthread.edits.TuskyTagHandler.Companion.DELETED_TEXT_EL
import com.keylesspalace.tusky.components.viewthread.edits.TuskyTagHandler.Companion.INSERTED_TEXT_EL
import com.keylesspalace.tusky.entity.StatusEdit
import com.keylesspalace.tusky.network.MastodonApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
Add "Refresh" accessibility menu (#3121) * Add "Refresh" accessibility menu to TimelineFragment Per https://developer.android.com/reference/androidx/swiperefreshlayout/widget/SwipeRefreshLayout the layout does not provide accessibility events, and a menu item should be provided as an alternative method for refreshing the content. In `TimelineFragment`: - Implement the `MenuProvider` interface so it can populate the action bar menu in activities that host the fragment - Create a "Refresh" menu item, and refresh the state when it is selected `MainActivity` has to change how the menu is created, so that fragments can add items to it. In `MainActivity`: - Call `setSupportActionBar` so `mainToolbar` participates in menus - Implement the `MenuProvider` interface, and move menu creation there - Set the title via supportActionBar * Never show the refresh item as a menubar action Per guidelines in https://developer.android.com/develop/ui/views/touch-and-input/swipe/add-swipe-interface#AddRefreshAction * Add "Refresh" menu item for AccountMediaFragment Also, fix the colour of the refresh progress indicator * Implement "Refresh" for AnnouncementsActivity * Add "Refresh" menu for ConversationsFragment * Keep the tabs adapter over the life of the viewpager Make `tabs` `var` instead of `val` in `MainPagerAdapter` so it can be updated when tabs change. Then detach the `tabLayoutMediator`, update the tabs, and call `notifyItemRangeChanged` in `setupTabs()`. This fixes a bug (not sure if it's this code, or in ViewPager2) where assigning a new adapter to the view pager seemed to result in a leak of one or more fragments. This wasn't user-visible, but it's a leak, and it becomes user-visible when fragments want to display menus. This also fixes two other bugs: 1. Be on the left-most tab. Scroll down a bit. Then modify the tabs at "Account preferences > tabs", but keep the left-most tab as-is. Then go back to MainActivity. Your reading position in the left-most tab has been jumped to the top. 2. Be on any non-left-most tab. Then modify the tab list by reordering tabs (adding/removing tabs is also OK). Then go back to MainActivity. Your tab selection has been overridden, and the left-most tab has been selected. Because the fragments are not destroyed unnecessarily your reading position is retained. And it remembers the tab you had selected, and as long as that tab is still present you will be returned to it, even if it's changed position in the list. Fixes https://github.com/tuskyapp/Tusky/issues/3251 * Add "Refresh" menu for ScheduledStatusActivity * Lint * Add "Refresh" menu for SearchFragment / SearchActivity * Explicitly set the searchview width Using "collapseActionView" requires the user to press "Back" twice to exit the activity, which is not acceptable. * Move toolbar handling in to ViewThreadActivity Previous code had the toolbar in the fragment's layout. Refactor to make consistent with other activities, and move the toolbar in to the activity layout. Implement MenuProvider in ViewThreadFragment to adjust the menu in the activity. * Add "Refresh" menu to ViewThreadFragment * Implement "Refresh" for ViewEditsFragment * Lint * Add "Refresh" menu to ReportStatusesFragment * Add "Refresh" menu to NotificationsFragment * Rename menu resource files Be consistent with the layout resource files, which have an activity/fragment prefix, then the lower_snake_case name of the activity or fragment it's for. * Only enable refresh menu if swiptorefresh is enabled Some timelines don't have swipetorefresh enabled (e.g., those shown on AccountActivity). In those cases don't add the refresh menu, rely on the hosting activity to provide it. Update AccountActivity to provide the refresh menu item.
2023-03-01 19:58:18 +01:00
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.pageseeder.diffx.api.LoadingException
import org.pageseeder.diffx.api.Operator
import org.pageseeder.diffx.config.DiffConfig
import org.pageseeder.diffx.config.TextGranularity
import org.pageseeder.diffx.config.WhiteSpaceProcessing
import org.pageseeder.diffx.core.OptimisticXMLProcessor
import org.pageseeder.diffx.format.XMLDiffOutput
import org.pageseeder.diffx.load.SAXLoader
import org.pageseeder.diffx.token.XMLToken
import org.pageseeder.diffx.token.XMLTokenType
import org.pageseeder.diffx.token.impl.SpaceToken
import org.pageseeder.diffx.xml.NamespaceSet
import org.pageseeder.xmlwriter.XML.NamespaceAware
import org.pageseeder.xmlwriter.XMLStringWriter
import javax.inject.Inject
class ViewEditsViewModel @Inject constructor(private val api: MastodonApi) : ViewModel() {
private val _uiState: MutableStateFlow<EditsUiState> = MutableStateFlow(EditsUiState.Initial)
Add "Refresh" accessibility menu (#3121) * Add "Refresh" accessibility menu to TimelineFragment Per https://developer.android.com/reference/androidx/swiperefreshlayout/widget/SwipeRefreshLayout the layout does not provide accessibility events, and a menu item should be provided as an alternative method for refreshing the content. In `TimelineFragment`: - Implement the `MenuProvider` interface so it can populate the action bar menu in activities that host the fragment - Create a "Refresh" menu item, and refresh the state when it is selected `MainActivity` has to change how the menu is created, so that fragments can add items to it. In `MainActivity`: - Call `setSupportActionBar` so `mainToolbar` participates in menus - Implement the `MenuProvider` interface, and move menu creation there - Set the title via supportActionBar * Never show the refresh item as a menubar action Per guidelines in https://developer.android.com/develop/ui/views/touch-and-input/swipe/add-swipe-interface#AddRefreshAction * Add "Refresh" menu item for AccountMediaFragment Also, fix the colour of the refresh progress indicator * Implement "Refresh" for AnnouncementsActivity * Add "Refresh" menu for ConversationsFragment * Keep the tabs adapter over the life of the viewpager Make `tabs` `var` instead of `val` in `MainPagerAdapter` so it can be updated when tabs change. Then detach the `tabLayoutMediator`, update the tabs, and call `notifyItemRangeChanged` in `setupTabs()`. This fixes a bug (not sure if it's this code, or in ViewPager2) where assigning a new adapter to the view pager seemed to result in a leak of one or more fragments. This wasn't user-visible, but it's a leak, and it becomes user-visible when fragments want to display menus. This also fixes two other bugs: 1. Be on the left-most tab. Scroll down a bit. Then modify the tabs at "Account preferences > tabs", but keep the left-most tab as-is. Then go back to MainActivity. Your reading position in the left-most tab has been jumped to the top. 2. Be on any non-left-most tab. Then modify the tab list by reordering tabs (adding/removing tabs is also OK). Then go back to MainActivity. Your tab selection has been overridden, and the left-most tab has been selected. Because the fragments are not destroyed unnecessarily your reading position is retained. And it remembers the tab you had selected, and as long as that tab is still present you will be returned to it, even if it's changed position in the list. Fixes https://github.com/tuskyapp/Tusky/issues/3251 * Add "Refresh" menu for ScheduledStatusActivity * Lint * Add "Refresh" menu for SearchFragment / SearchActivity * Explicitly set the searchview width Using "collapseActionView" requires the user to press "Back" twice to exit the activity, which is not acceptable. * Move toolbar handling in to ViewThreadActivity Previous code had the toolbar in the fragment's layout. Refactor to make consistent with other activities, and move the toolbar in to the activity layout. Implement MenuProvider in ViewThreadFragment to adjust the menu in the activity. * Add "Refresh" menu to ViewThreadFragment * Implement "Refresh" for ViewEditsFragment * Lint * Add "Refresh" menu to ReportStatusesFragment * Add "Refresh" menu to NotificationsFragment * Rename menu resource files Be consistent with the layout resource files, which have an activity/fragment prefix, then the lower_snake_case name of the activity or fragment it's for. * Only enable refresh menu if swiptorefresh is enabled Some timelines don't have swipetorefresh enabled (e.g., those shown on AccountActivity). In those cases don't add the refresh menu, rely on the hosting activity to provide it. Update AccountActivity to provide the refresh menu item.
2023-03-01 19:58:18 +01:00
val uiState: StateFlow<EditsUiState> = _uiState.asStateFlow()
/** The API call to fetch edit history returned less than two items */
object MissingEditsException : Exception()
fun loadEdits(statusId: String, force: Boolean = false, refreshing: Boolean = false) {
Add "Refresh" accessibility menu (#3121) * Add "Refresh" accessibility menu to TimelineFragment Per https://developer.android.com/reference/androidx/swiperefreshlayout/widget/SwipeRefreshLayout the layout does not provide accessibility events, and a menu item should be provided as an alternative method for refreshing the content. In `TimelineFragment`: - Implement the `MenuProvider` interface so it can populate the action bar menu in activities that host the fragment - Create a "Refresh" menu item, and refresh the state when it is selected `MainActivity` has to change how the menu is created, so that fragments can add items to it. In `MainActivity`: - Call `setSupportActionBar` so `mainToolbar` participates in menus - Implement the `MenuProvider` interface, and move menu creation there - Set the title via supportActionBar * Never show the refresh item as a menubar action Per guidelines in https://developer.android.com/develop/ui/views/touch-and-input/swipe/add-swipe-interface#AddRefreshAction * Add "Refresh" menu item for AccountMediaFragment Also, fix the colour of the refresh progress indicator * Implement "Refresh" for AnnouncementsActivity * Add "Refresh" menu for ConversationsFragment * Keep the tabs adapter over the life of the viewpager Make `tabs` `var` instead of `val` in `MainPagerAdapter` so it can be updated when tabs change. Then detach the `tabLayoutMediator`, update the tabs, and call `notifyItemRangeChanged` in `setupTabs()`. This fixes a bug (not sure if it's this code, or in ViewPager2) where assigning a new adapter to the view pager seemed to result in a leak of one or more fragments. This wasn't user-visible, but it's a leak, and it becomes user-visible when fragments want to display menus. This also fixes two other bugs: 1. Be on the left-most tab. Scroll down a bit. Then modify the tabs at "Account preferences > tabs", but keep the left-most tab as-is. Then go back to MainActivity. Your reading position in the left-most tab has been jumped to the top. 2. Be on any non-left-most tab. Then modify the tab list by reordering tabs (adding/removing tabs is also OK). Then go back to MainActivity. Your tab selection has been overridden, and the left-most tab has been selected. Because the fragments are not destroyed unnecessarily your reading position is retained. And it remembers the tab you had selected, and as long as that tab is still present you will be returned to it, even if it's changed position in the list. Fixes https://github.com/tuskyapp/Tusky/issues/3251 * Add "Refresh" menu for ScheduledStatusActivity * Lint * Add "Refresh" menu for SearchFragment / SearchActivity * Explicitly set the searchview width Using "collapseActionView" requires the user to press "Back" twice to exit the activity, which is not acceptable. * Move toolbar handling in to ViewThreadActivity Previous code had the toolbar in the fragment's layout. Refactor to make consistent with other activities, and move the toolbar in to the activity layout. Implement MenuProvider in ViewThreadFragment to adjust the menu in the activity. * Add "Refresh" menu to ViewThreadFragment * Implement "Refresh" for ViewEditsFragment * Lint * Add "Refresh" menu to ReportStatusesFragment * Add "Refresh" menu to NotificationsFragment * Rename menu resource files Be consistent with the layout resource files, which have an activity/fragment prefix, then the lower_snake_case name of the activity or fragment it's for. * Only enable refresh menu if swiptorefresh is enabled Some timelines don't have swipetorefresh enabled (e.g., those shown on AccountActivity). In those cases don't add the refresh menu, rely on the hosting activity to provide it. Update AccountActivity to provide the refresh menu item.
2023-03-01 19:58:18 +01:00
if (!force && _uiState.value !is EditsUiState.Initial) return
if (refreshing) {
_uiState.value = EditsUiState.Refreshing
} else {
_uiState.value = EditsUiState.Loading
}
viewModelScope.launch {
val edits = api.statusEdits(statusId).getOrElse {
_uiState.value = EditsUiState.Error(it)
return@launch
}
// `edits` might have fewer than the minimum number of entries because of
// https://github.com/mastodon/mastodon/issues/25398.
if (edits.size < 2) {
_uiState.value = EditsUiState.Error(MissingEditsException)
return@launch
}
// Diff each status' content against the previous version, producing new
// content with additional `ins` or `del` elements marking inserted or
// deleted content.
//
// This can be CPU intensive depending on the number of edits and the size
// of each, so don't run this on Dispatchers.Main.
viewModelScope.launch(Dispatchers.Default) {
val sortedEdits = edits.sortedBy { it.createdAt }
.reversed()
.toMutableList()
SAXLoader.setXMLReaderClass("org.xmlpull.v1.sax2.Driver")
val loader = SAXLoader()
loader.config = DiffConfig(
false,
WhiteSpaceProcessing.PRESERVE,
TextGranularity.SPACE_WORD
)
val processor = OptimisticXMLProcessor()
processor.setCoalesce(true)
val output = HtmlDiffOutput()
try {
// The XML processor expects `br` to be closed
var currentContent =
loader.load(sortedEdits[0].content.replace("<br>", "<br/>"))
var previousContent =
loader.load(sortedEdits[1].content.replace("<br>", "<br/>"))
for (i in 1 until sortedEdits.size) {
processor.diff(previousContent, currentContent, output)
sortedEdits[i - 1] = sortedEdits[i - 1].copy(
content = output.xml.toString()
)
if (i < sortedEdits.size - 1) {
currentContent = previousContent
previousContent = loader.load(
sortedEdits[i + 1].content.replace("<br>", "<br/>")
)
}
}
_uiState.value = EditsUiState.Success(sortedEdits)
} catch (_: LoadingException) {
// Something failed parsing the XML from the server. Rather than
// show an error just return the sorted edits so the user can at
// least visually scan the differences.
_uiState.value = EditsUiState.Success(sortedEdits)
Add "Refresh" accessibility menu (#3121) * Add "Refresh" accessibility menu to TimelineFragment Per https://developer.android.com/reference/androidx/swiperefreshlayout/widget/SwipeRefreshLayout the layout does not provide accessibility events, and a menu item should be provided as an alternative method for refreshing the content. In `TimelineFragment`: - Implement the `MenuProvider` interface so it can populate the action bar menu in activities that host the fragment - Create a "Refresh" menu item, and refresh the state when it is selected `MainActivity` has to change how the menu is created, so that fragments can add items to it. In `MainActivity`: - Call `setSupportActionBar` so `mainToolbar` participates in menus - Implement the `MenuProvider` interface, and move menu creation there - Set the title via supportActionBar * Never show the refresh item as a menubar action Per guidelines in https://developer.android.com/develop/ui/views/touch-and-input/swipe/add-swipe-interface#AddRefreshAction * Add "Refresh" menu item for AccountMediaFragment Also, fix the colour of the refresh progress indicator * Implement "Refresh" for AnnouncementsActivity * Add "Refresh" menu for ConversationsFragment * Keep the tabs adapter over the life of the viewpager Make `tabs` `var` instead of `val` in `MainPagerAdapter` so it can be updated when tabs change. Then detach the `tabLayoutMediator`, update the tabs, and call `notifyItemRangeChanged` in `setupTabs()`. This fixes a bug (not sure if it's this code, or in ViewPager2) where assigning a new adapter to the view pager seemed to result in a leak of one or more fragments. This wasn't user-visible, but it's a leak, and it becomes user-visible when fragments want to display menus. This also fixes two other bugs: 1. Be on the left-most tab. Scroll down a bit. Then modify the tabs at "Account preferences > tabs", but keep the left-most tab as-is. Then go back to MainActivity. Your reading position in the left-most tab has been jumped to the top. 2. Be on any non-left-most tab. Then modify the tab list by reordering tabs (adding/removing tabs is also OK). Then go back to MainActivity. Your tab selection has been overridden, and the left-most tab has been selected. Because the fragments are not destroyed unnecessarily your reading position is retained. And it remembers the tab you had selected, and as long as that tab is still present you will be returned to it, even if it's changed position in the list. Fixes https://github.com/tuskyapp/Tusky/issues/3251 * Add "Refresh" menu for ScheduledStatusActivity * Lint * Add "Refresh" menu for SearchFragment / SearchActivity * Explicitly set the searchview width Using "collapseActionView" requires the user to press "Back" twice to exit the activity, which is not acceptable. * Move toolbar handling in to ViewThreadActivity Previous code had the toolbar in the fragment's layout. Refactor to make consistent with other activities, and move the toolbar in to the activity layout. Implement MenuProvider in ViewThreadFragment to adjust the menu in the activity. * Add "Refresh" menu to ViewThreadFragment * Implement "Refresh" for ViewEditsFragment * Lint * Add "Refresh" menu to ReportStatusesFragment * Add "Refresh" menu to NotificationsFragment * Rename menu resource files Be consistent with the layout resource files, which have an activity/fragment prefix, then the lower_snake_case name of the activity or fragment it's for. * Only enable refresh menu if swiptorefresh is enabled Some timelines don't have swipetorefresh enabled (e.g., those shown on AccountActivity). In those cases don't add the refresh menu, rely on the hosting activity to provide it. Update AccountActivity to provide the refresh menu item.
2023-03-01 19:58:18 +01:00
}
}
}
}
companion object {
const val TAG = "ViewEditsViewModel"
}
}
sealed interface EditsUiState {
data object Initial : EditsUiState
data object Loading : EditsUiState
Add "Refresh" accessibility menu (#3121) * Add "Refresh" accessibility menu to TimelineFragment Per https://developer.android.com/reference/androidx/swiperefreshlayout/widget/SwipeRefreshLayout the layout does not provide accessibility events, and a menu item should be provided as an alternative method for refreshing the content. In `TimelineFragment`: - Implement the `MenuProvider` interface so it can populate the action bar menu in activities that host the fragment - Create a "Refresh" menu item, and refresh the state when it is selected `MainActivity` has to change how the menu is created, so that fragments can add items to it. In `MainActivity`: - Call `setSupportActionBar` so `mainToolbar` participates in menus - Implement the `MenuProvider` interface, and move menu creation there - Set the title via supportActionBar * Never show the refresh item as a menubar action Per guidelines in https://developer.android.com/develop/ui/views/touch-and-input/swipe/add-swipe-interface#AddRefreshAction * Add "Refresh" menu item for AccountMediaFragment Also, fix the colour of the refresh progress indicator * Implement "Refresh" for AnnouncementsActivity * Add "Refresh" menu for ConversationsFragment * Keep the tabs adapter over the life of the viewpager Make `tabs` `var` instead of `val` in `MainPagerAdapter` so it can be updated when tabs change. Then detach the `tabLayoutMediator`, update the tabs, and call `notifyItemRangeChanged` in `setupTabs()`. This fixes a bug (not sure if it's this code, or in ViewPager2) where assigning a new adapter to the view pager seemed to result in a leak of one or more fragments. This wasn't user-visible, but it's a leak, and it becomes user-visible when fragments want to display menus. This also fixes two other bugs: 1. Be on the left-most tab. Scroll down a bit. Then modify the tabs at "Account preferences > tabs", but keep the left-most tab as-is. Then go back to MainActivity. Your reading position in the left-most tab has been jumped to the top. 2. Be on any non-left-most tab. Then modify the tab list by reordering tabs (adding/removing tabs is also OK). Then go back to MainActivity. Your tab selection has been overridden, and the left-most tab has been selected. Because the fragments are not destroyed unnecessarily your reading position is retained. And it remembers the tab you had selected, and as long as that tab is still present you will be returned to it, even if it's changed position in the list. Fixes https://github.com/tuskyapp/Tusky/issues/3251 * Add "Refresh" menu for ScheduledStatusActivity * Lint * Add "Refresh" menu for SearchFragment / SearchActivity * Explicitly set the searchview width Using "collapseActionView" requires the user to press "Back" twice to exit the activity, which is not acceptable. * Move toolbar handling in to ViewThreadActivity Previous code had the toolbar in the fragment's layout. Refactor to make consistent with other activities, and move the toolbar in to the activity layout. Implement MenuProvider in ViewThreadFragment to adjust the menu in the activity. * Add "Refresh" menu to ViewThreadFragment * Implement "Refresh" for ViewEditsFragment * Lint * Add "Refresh" menu to ReportStatusesFragment * Add "Refresh" menu to NotificationsFragment * Rename menu resource files Be consistent with the layout resource files, which have an activity/fragment prefix, then the lower_snake_case name of the activity or fragment it's for. * Only enable refresh menu if swiptorefresh is enabled Some timelines don't have swipetorefresh enabled (e.g., those shown on AccountActivity). In those cases don't add the refresh menu, rely on the hosting activity to provide it. Update AccountActivity to provide the refresh menu item.
2023-03-01 19:58:18 +01:00
// "Refreshing" state is necessary, otherwise a refresh state transition is Success -> Success,
// and state flows don't emit repeated states, so the UI never updates.
data object Refreshing : EditsUiState
class Error(val throwable: Throwable) : EditsUiState
data class Success(
val edits: List<StatusEdit>
) : EditsUiState
}
/**
* Add elements wrapping inserted or deleted content.
*/
class HtmlDiffOutput : XMLDiffOutput {
/** XML Output */
lateinit var xml: XMLStringWriter
private set
override fun start() {
xml = XMLStringWriter(NamespaceAware.Yes)
}
override fun handle(operator: Operator, token: XMLToken) {
if (operator.isEdit) {
handleEdit(operator, token)
} else {
token.toXML(xml)
}
}
override fun end() {
xml.flush()
}
override fun setWriteXMLDeclaration(show: Boolean) {
// This space intentionally left blank
}
override fun setNamespaces(namespaces: NamespaceSet?) {
// This space intentionally left blank
}
private fun handleEdit(operator: Operator, token: XMLToken) {
if (token == SpaceToken.NEW_LINE) {
if (operator == Operator.INS) {
token.toXML(xml)
}
return
}
when (token.type) {
XMLTokenType.START_ELEMENT -> token.toXML(xml)
XMLTokenType.END_ELEMENT -> token.toXML(xml)
XMLTokenType.TEXT -> {
// wrap the characters in a <tusky-ins/tusky-del> element
when (operator) {
Operator.DEL -> DELETED_TEXT_EL
Operator.INS -> INSERTED_TEXT_EL
else -> null
}?.let {
xml.openElement(it, false)
}
token.toXML(xml)
xml.closeElement()
}
else -> {
// Only include inserted content
if (operator === Operator.INS) {
token.toXML(xml)
}
}
}
}
}