move Html parsing to ViewData (#2414)

* move Html parsing to ViewData

* refactor reports to use viewdata

* cleanup code

* refactor conversations

* fix getEditableText

* rename StatusParsingHelper

* fix tests

* commit db schema file

* add file header

* rename helper function to parseAsMastodonHtml

* order imports correctly

* move mapping off main thread to default dispatcher

* fix ktlint
This commit is contained in:
Konrad Pozniak 2022-04-15 13:20:27 +02:00 committed by GitHub
commit 3e849244f9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 1232 additions and 500 deletions

View file

@ -78,7 +78,7 @@ import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.getDomain
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.openLink
import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.util.setClickableText
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
@ -375,12 +375,11 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
}
}
viewModel.accountFieldData.observe(
this,
{
accountFieldAdapter.fields = it
accountFieldAdapter.notifyDataSetChanged()
}
)
this
) {
accountFieldAdapter.fields = it
accountFieldAdapter.notifyDataSetChanged()
}
viewModel.noteSaved.observe(this) {
binding.saveNoteInfo.visible(it, View.INVISIBLE)
}
@ -395,11 +394,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
adapter.refreshContent()
}
viewModel.isRefreshing.observe(
this,
{ isRefreshing ->
binding.swipeToRefreshLayout.isRefreshing = isRefreshing == true
}
)
this
) { isRefreshing ->
binding.swipeToRefreshLayout.isRefreshing = isRefreshing == true
}
binding.swipeToRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
}
@ -410,7 +408,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
binding.accountUsernameTextView.text = usernameFormatted
binding.accountDisplayNameTextView.text = account.name.emojify(account.emojis, binding.accountDisplayNameTextView, animateEmojis)
val emojifiedNote = account.note.emojify(account.emojis, binding.accountNoteTextView, animateEmojis)
val emojifiedNote = account.note.parseAsMastodonHtml().emojify(account.emojis, binding.accountNoteTextView, animateEmojis)
setClickableText(binding.accountNoteTextView, emojifiedNote, emptyList(), null, this)
// accountFieldAdapter.fields = account.fields ?: emptyList()

View file

@ -29,6 +29,7 @@ import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.Either
import com.keylesspalace.tusky.util.createClickableText
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.util.setClickableText
class AccountFieldAdapter(
@ -65,7 +66,7 @@ class AccountFieldAdapter(
val emojifiedName = field.name.emojify(emojis, nameTextView, animateEmojis)
nameTextView.text = emojifiedName
val emojifiedValue = field.value.emojify(emojis, valueTextView, animateEmojis)
val emojifiedValue = field.value.parseAsMastodonHtml().emojify(emojis, valueTextView, animateEmojis)
setClickableText(valueTextView, emojifiedValue, emptyList(), null, linkListener)
if (field.verifiedAt != null) {

View file

@ -26,7 +26,7 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions
class ConversationAdapter(
private val statusDisplayOptions: StatusDisplayOptions,
private val listener: StatusActionListener
) : PagingDataAdapter<ConversationEntity, ConversationViewHolder>(CONVERSATION_COMPARATOR) {
) : PagingDataAdapter<ConversationViewData, ConversationViewHolder>(CONVERSATION_COMPARATOR) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConversationViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_conversation, parent, false)
@ -37,17 +37,13 @@ class ConversationAdapter(
holder.setupWithConversation(getItem(position))
}
fun item(position: Int): ConversationEntity? {
return getItem(position)
}
companion object {
val CONVERSATION_COMPARATOR = object : DiffUtil.ItemCallback<ConversationEntity>() {
override fun areItemsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean {
val CONVERSATION_COMPARATOR = object : DiffUtil.ItemCallback<ConversationViewData>() {
override fun areItemsTheSame(oldItem: ConversationViewData, newItem: ConversationViewData): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean {
override fun areContentsTheSame(oldItem: ConversationViewData, newItem: ConversationViewData): Boolean {
return oldItem == newItem
}
}

View file

@ -15,7 +15,6 @@
package com.keylesspalace.tusky.components.conversation
import android.text.Spanned
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.TypeConverters
@ -27,7 +26,7 @@ import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.util.shouldTrimStatus
import com.keylesspalace.tusky.viewdata.StatusViewData
import java.util.Date
@Entity(primaryKeys = ["id", "accountId"])
@ -38,7 +37,16 @@ data class ConversationEntity(
val accounts: List<ConversationAccountEntity>,
val unread: Boolean,
@Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity
)
) {
fun toViewData(): ConversationViewData {
return ConversationViewData(
id = id,
accounts = accounts,
unread = unread,
lastStatus = lastStatus.toViewData()
)
}
}
data class ConversationAccountEntity(
val id: String,
@ -67,7 +75,7 @@ data class ConversationStatusEntity(
val inReplyToId: String?,
val inReplyToAccountId: String?,
val account: ConversationAccountEntity,
val content: Spanned,
val content: String,
val createdAt: Date,
val emojis: List<Emoji>,
val favouritesCount: Int,
@ -80,95 +88,43 @@ data class ConversationStatusEntity(
val tags: List<HashTag>?,
val showingHiddenContent: Boolean,
val expanded: Boolean,
val collapsible: Boolean,
val collapsed: Boolean,
val muted: Boolean,
val poll: Poll?
) {
/** its necessary to override this because Spanned.equals does not work as expected */
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ConversationStatusEntity
if (id != other.id) return false
if (url != other.url) return false
if (inReplyToId != other.inReplyToId) return false
if (inReplyToAccountId != other.inReplyToAccountId) return false
if (account != other.account) return false
if (content.toString() != other.content.toString()) return false
if (createdAt != other.createdAt) return false
if (emojis != other.emojis) return false
if (favouritesCount != other.favouritesCount) return false
if (favourited != other.favourited) return false
if (sensitive != other.sensitive) return false
if (spoilerText != other.spoilerText) return false
if (attachments != other.attachments) return false
if (mentions != other.mentions) return false
if (tags != other.tags) return false
if (showingHiddenContent != other.showingHiddenContent) return false
if (expanded != other.expanded) return false
if (collapsible != other.collapsible) return false
if (collapsed != other.collapsed) return false
if (muted != other.muted) return false
if (poll != other.poll) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + (url?.hashCode() ?: 0)
result = 31 * result + (inReplyToId?.hashCode() ?: 0)
result = 31 * result + (inReplyToAccountId?.hashCode() ?: 0)
result = 31 * result + account.hashCode()
result = 31 * result + content.toString().hashCode()
result = 31 * result + createdAt.hashCode()
result = 31 * result + emojis.hashCode()
result = 31 * result + favouritesCount
result = 31 * result + favourited.hashCode()
result = 31 * result + sensitive.hashCode()
result = 31 * result + spoilerText.hashCode()
result = 31 * result + attachments.hashCode()
result = 31 * result + mentions.hashCode()
result = 31 * result + tags.hashCode()
result = 31 * result + showingHiddenContent.hashCode()
result = 31 * result + expanded.hashCode()
result = 31 * result + collapsible.hashCode()
result = 31 * result + collapsed.hashCode()
result = 31 * result + muted.hashCode()
result = 31 * result + poll.hashCode()
return result
}
fun toStatus(): Status {
return Status(
id = id,
url = url,
account = account.toAccount(),
inReplyToId = inReplyToId,
inReplyToAccountId = inReplyToAccountId,
content = content,
reblog = null,
createdAt = createdAt,
emojis = emojis,
reblogsCount = 0,
favouritesCount = favouritesCount,
reblogged = false,
favourited = favourited,
bookmarked = bookmarked,
sensitive = sensitive,
spoilerText = spoilerText,
visibility = Status.Visibility.DIRECT,
attachments = attachments,
mentions = mentions,
tags = tags,
application = null,
pinned = false,
muted = muted,
poll = poll,
card = null
fun toViewData(): StatusViewData.Concrete {
return StatusViewData.Concrete(
status = Status(
id = id,
url = url,
account = account.toAccount(),
inReplyToId = inReplyToId,
inReplyToAccountId = inReplyToAccountId,
content = content,
reblog = null,
createdAt = createdAt,
emojis = emojis,
reblogsCount = 0,
favouritesCount = favouritesCount,
reblogged = false,
favourited = favourited,
bookmarked = bookmarked,
sensitive = sensitive,
spoilerText = spoilerText,
visibility = Status.Visibility.DIRECT,
attachments = attachments,
mentions = mentions,
tags = tags,
application = null,
pinned = false,
muted = muted,
poll = poll,
card = null
),
isExpanded = expanded,
isShowingContent = showingHiddenContent,
isCollapsed = collapsed
)
}
}
@ -202,7 +158,6 @@ fun Status.toEntity() =
tags = tags,
showingHiddenContent = false,
expanded = false,
collapsible = shouldTrimStatus(content),
collapsed = true,
muted = muted ?: false,
poll = poll

View file

@ -0,0 +1,87 @@
/* 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.conversation
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.viewdata.StatusViewData
data class ConversationViewData(
val id: String,
val accounts: List<ConversationAccountEntity>,
val unread: Boolean,
val lastStatus: StatusViewData.Concrete
) {
fun toEntity(
accountId: Long,
favourited: Boolean = lastStatus.status.favourited,
bookmarked: Boolean = lastStatus.status.bookmarked,
muted: Boolean = lastStatus.status.muted ?: false,
poll: Poll? = lastStatus.status.poll,
expanded: Boolean = lastStatus.isExpanded,
collapsed: Boolean = lastStatus.isCollapsed,
showingHiddenContent: Boolean = lastStatus.isShowingContent
): ConversationEntity {
return ConversationEntity(
accountId = accountId,
id = id,
accounts = accounts,
unread = unread,
lastStatus = lastStatus.toConversationStatusEntity(
favourited = favourited,
bookmarked = bookmarked,
muted = muted,
poll = poll,
expanded = expanded,
collapsed = collapsed,
showingHiddenContent = showingHiddenContent
)
)
}
}
fun StatusViewData.Concrete.toConversationStatusEntity(
favourited: Boolean = status.favourited,
bookmarked: Boolean = status.bookmarked,
muted: Boolean = status.muted ?: false,
poll: Poll? = status.poll,
expanded: Boolean = isExpanded,
collapsed: Boolean = isCollapsed,
showingHiddenContent: Boolean = isShowingContent
): ConversationStatusEntity {
return ConversationStatusEntity(
id = id,
url = status.url,
inReplyToId = status.inReplyToId,
inReplyToAccountId = status.inReplyToAccountId,
account = status.account.toEntity(),
content = status.content,
createdAt = status.createdAt,
emojis = status.emojis,
favouritesCount = status.favouritesCount,
favourited = favourited,
bookmarked = bookmarked,
sensitive = status.sensitive,
spoilerText = status.spoilerText,
attachments = status.attachments,
mentions = status.mentions,
tags = status.tags,
showingHiddenContent = showingHiddenContent,
expanded = expanded,
collapsed = collapsed,
muted = muted,
poll = poll
)
}

View file

@ -28,11 +28,14 @@ import androidx.recyclerview.widget.RecyclerView;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder;
import com.keylesspalace.tusky.entity.Attachment;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.entity.TimelineAccount;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.ImageLoadingHelper;
import com.keylesspalace.tusky.util.SmartLengthInputFilter;
import com.keylesspalace.tusky.util.StatusDisplayOptions;
import com.keylesspalace.tusky.viewdata.PollViewDataKt;
import com.keylesspalace.tusky.viewdata.StatusViewData;
import java.util.List;
@ -69,11 +72,12 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
return context.getResources().getDimensionPixelSize(R.dimen.status_media_preview_height);
}
void setupWithConversation(ConversationEntity conversation) {
ConversationStatusEntity status = conversation.getLastStatus();
ConversationAccountEntity account = status.getAccount();
void setupWithConversation(ConversationViewData conversation) {
StatusViewData.Concrete statusViewData = conversation.getLastStatus();
Status status = statusViewData.getStatus();
TimelineAccount account = status.getAccount();
setupCollapsedState(status.getCollapsible(), status.getCollapsed(), status.getExpanded(), status.getSpoilerText(), listener);
setupCollapsedState(statusViewData.isCollapsible(), statusViewData.isCollapsed(), statusViewData.isExpanded(), statusViewData.getSpoilerText(), listener);
setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions);
setUsername(account.getUsername());
@ -84,7 +88,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
List<Attachment> attachments = status.getAttachments();
boolean sensitive = status.getSensitive();
if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
setMediaPreviews(attachments, sensitive, listener, status.getShowingHiddenContent(),
setMediaPreviews(attachments, sensitive, listener, statusViewData.isShowingContent(),
statusDisplayOptions.useBlurhash());
if (attachments.size() == 0) {
@ -95,7 +99,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
mediaLabel.setVisibility(View.GONE);
}
} else {
setMediaLabel(attachments, sensitive, listener, status.getShowingHiddenContent());
setMediaLabel(attachments, sensitive, listener, statusViewData.isShowingContent());
// Hide all unused views.
mediaPreviews[0].setVisibility(View.GONE);
mediaPreviews[1].setVisibility(View.GONE);
@ -104,10 +108,10 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
hideSensitiveMediaWarning();
}
setupButtons(listener, account.getId(), status.getContent().toString(),
setupButtons(listener, account.getId(), statusViewData.getContent().toString(),
statusDisplayOptions);
setSpoilerAndContent(status.getExpanded(), status.getContent(), status.getSpoilerText(),
setSpoilerAndContent(statusViewData.isExpanded(), statusViewData.getContent(), status.getSpoilerText(),
status.getMentions(), status.getTags(), status.getEmojis(),
PollViewDataKt.toViewData(status.getPoll()), statusDisplayOptions, listener);

View file

@ -153,24 +153,24 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
}
override fun onFavourite(favourite: Boolean, position: Int) {
adapter.item(position)?.let { conversation ->
adapter.peek(position)?.let { conversation ->
viewModel.favourite(favourite, conversation)
}
}
override fun onBookmark(favourite: Boolean, position: Int) {
adapter.item(position)?.let { conversation ->
adapter.peek(position)?.let { conversation ->
viewModel.bookmark(favourite, conversation)
}
}
override fun onMore(view: View, position: Int) {
adapter.item(position)?.let { conversation ->
adapter.peek(position)?.let { conversation ->
val popup = PopupMenu(requireContext(), view)
popup.inflate(R.menu.conversation_more)
if (conversation.lastStatus.muted) {
if (conversation.lastStatus.status.muted == true) {
popup.menu.removeItem(R.id.status_mute_conversation)
} else {
popup.menu.removeItem(R.id.status_unmute_conversation)
@ -189,14 +189,14 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
}
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
adapter.item(position)?.let { conversation ->
viewMedia(attachmentIndex, AttachmentViewData.list(conversation.lastStatus.toStatus()), view)
adapter.peek(position)?.let { conversation ->
viewMedia(attachmentIndex, AttachmentViewData.list(conversation.lastStatus.status), view)
}
}
override fun onViewThread(position: Int) {
adapter.item(position)?.let { conversation ->
viewThread(conversation.lastStatus.id, conversation.lastStatus.url)
adapter.peek(position)?.let { conversation ->
viewThread(conversation.lastStatus.id, conversation.lastStatus.status.url)
}
}
@ -205,13 +205,13 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
}
override fun onExpandedChange(expanded: Boolean, position: Int) {
adapter.item(position)?.let { conversation ->
adapter.peek(position)?.let { conversation ->
viewModel.expandHiddenStatus(expanded, conversation)
}
}
override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
adapter.item(position)?.let { conversation ->
adapter.peek(position)?.let { conversation ->
viewModel.showContent(isShowing, conversation)
}
}
@ -221,7 +221,7 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
}
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
adapter.item(position)?.let { conversation ->
adapter.peek(position)?.let { conversation ->
viewModel.collapseLongStatus(isCollapsed, conversation)
}
}
@ -241,12 +241,12 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
}
override fun onReply(position: Int) {
adapter.item(position)?.let { conversation ->
reply(conversation.lastStatus.toStatus())
adapter.peek(position)?.let { conversation ->
reply(conversation.lastStatus.status)
}
}
private fun deleteConversation(conversation: ConversationEntity) {
private fun deleteConversation(conversation: ConversationViewData) {
AlertDialog.Builder(requireContext())
.setMessage(R.string.dialog_delete_conversation_warning)
.setNegativeButton(android.R.string.cancel, null)
@ -268,7 +268,7 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
}
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
adapter.item(position)?.let { conversation ->
adapter.peek(position)?.let { conversation ->
viewModel.voteInPoll(choices, conversation)
}
}

View file

@ -16,16 +16,18 @@
package com.keylesspalace.tusky.components.conversation
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.ExperimentalPagingApi
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.cachedIn
import androidx.paging.map
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.TimelineCases
import com.keylesspalace.tusky.util.RxAwareViewModel
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await
import javax.inject.Inject
@ -35,7 +37,7 @@ class ConversationsViewModel @Inject constructor(
private val database: AppDatabase,
private val accountManager: AccountManager,
private val api: MastodonApi
) : RxAwareViewModel() {
) : ViewModel() {
@OptIn(ExperimentalPagingApi::class)
val conversationFlow = Pager(
@ -44,104 +46,117 @@ class ConversationsViewModel @Inject constructor(
pagingSourceFactory = { database.conversationDao().conversationsForAccount(accountManager.activeAccount!!.id) }
)
.flow
.map { pagingData ->
pagingData.map { conversation -> conversation.toViewData() }
}
.cachedIn(viewModelScope)
fun favourite(favourite: Boolean, conversation: ConversationEntity) {
fun favourite(favourite: Boolean, conversation: ConversationViewData) {
viewModelScope.launch {
try {
timelineCases.favourite(conversation.lastStatus.id, favourite).await()
val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(favourited = favourite)
val newConversation = conversation.toEntity(
accountId = accountManager.activeAccount!!.id,
favourited = favourite
)
database.conversationDao().insert(newConversation)
saveConversationToDb(newConversation)
} catch (e: Exception) {
Log.w(TAG, "failed to favourite status", e)
}
}
}
fun bookmark(bookmark: Boolean, conversation: ConversationEntity) {
fun bookmark(bookmark: Boolean, conversation: ConversationViewData) {
viewModelScope.launch {
try {
timelineCases.bookmark(conversation.lastStatus.id, bookmark).await()
val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(bookmarked = bookmark)
val newConversation = conversation.toEntity(
accountId = accountManager.activeAccount!!.id,
bookmarked = bookmark
)
database.conversationDao().insert(newConversation)
saveConversationToDb(newConversation)
} catch (e: Exception) {
Log.w(TAG, "failed to bookmark status", e)
}
}
}
fun voteInPoll(choices: List<Int>, conversation: ConversationEntity) {
fun voteInPoll(choices: List<Int>, conversation: ConversationViewData) {
viewModelScope.launch {
try {
val poll = timelineCases.voteInPoll(conversation.lastStatus.id, conversation.lastStatus.poll?.id!!, choices).await()
val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(poll = poll)
val poll = timelineCases.voteInPoll(conversation.lastStatus.id, conversation.lastStatus.status.poll?.id!!, choices).await()
val newConversation = conversation.toEntity(
accountId = accountManager.activeAccount!!.id,
poll = poll
)
database.conversationDao().insert(newConversation)
saveConversationToDb(newConversation)
} catch (e: Exception) {
Log.w(TAG, "failed to vote in poll", e)
}
}
}
fun expandHiddenStatus(expanded: Boolean, conversation: ConversationEntity) {
fun expandHiddenStatus(expanded: Boolean, conversation: ConversationViewData) {
viewModelScope.launch {
val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(expanded = expanded)
val newConversation = conversation.toEntity(
accountId = accountManager.activeAccount!!.id,
expanded = expanded
)
saveConversationToDb(newConversation)
}
}
fun collapseLongStatus(collapsed: Boolean, conversation: ConversationEntity) {
fun collapseLongStatus(collapsed: Boolean, conversation: ConversationViewData) {
viewModelScope.launch {
val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(collapsed = collapsed)
val newConversation = conversation.toEntity(
accountId = accountManager.activeAccount!!.id,
collapsed = collapsed
)
saveConversationToDb(newConversation)
}
}
fun showContent(showing: Boolean, conversation: ConversationEntity) {
fun showContent(showing: Boolean, conversation: ConversationViewData) {
viewModelScope.launch {
val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(showingHiddenContent = showing)
val newConversation = conversation.toEntity(
accountId = accountManager.activeAccount!!.id,
showingHiddenContent = showing
)
saveConversationToDb(newConversation)
}
}
fun remove(conversation: ConversationEntity) {
fun remove(conversation: ConversationViewData) {
viewModelScope.launch {
try {
api.deleteConversation(conversationId = conversation.id)
database.conversationDao().delete(conversation)
database.conversationDao().delete(
id = conversation.id,
accountId = accountManager.activeAccount!!.id
)
} catch (e: Exception) {
Log.w(TAG, "failed to delete conversation", e)
}
}
}
fun muteConversation(conversation: ConversationEntity) {
fun muteConversation(conversation: ConversationViewData) {
viewModelScope.launch {
try {
val newStatus = timelineCases.muteConversation(
timelineCases.muteConversation(
conversation.lastStatus.id,
!conversation.lastStatus.muted
!(conversation.lastStatus.status.muted ?: false)
).await()
val newConversation = conversation.copy(
lastStatus = newStatus.toEntity()
val newConversation = conversation.toEntity(
accountId = accountManager.activeAccount!!.id,
muted = !(conversation.lastStatus.status.muted ?: false)
)
database.conversationDao().insert(newConversation)
@ -151,7 +166,7 @@ class ConversationsViewModel @Inject constructor(
}
}
suspend fun saveConversationToDb(conversation: ConversationEntity) {
private suspend fun saveConversationToDb(conversation: ConversationEntity) {
database.conversationDao().insert(conversation)
}

View file

@ -21,6 +21,7 @@ import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.cachedIn
import androidx.paging.map
import com.keylesspalace.tusky.appstore.BlockEvent
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.MuteEvent
@ -34,11 +35,13 @@ import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Resource
import com.keylesspalace.tusky.util.RxAwareViewModel
import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.toViewData
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -74,6 +77,11 @@ class ReportViewModel @Inject constructor(
pagingSourceFactory = { StatusesPagingSource(accountId, mastodonApi) }
).flow
}
.map { pagingData ->
/* TODO: refactor reports to use the isShowingContent / isExpanded / isCollapsed attributes from StatusViewData.Concrete
instead of StatusViewState */
pagingData.map { status -> status.toViewData(false, false, false) }
}
.cachedIn(viewModelScope)
private val selectedIds = HashSet<String>()
@ -155,7 +163,7 @@ class ReportViewModel @Inject constructor(
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ relationship ->
val muting = relationship?.muting == true
val muting = relationship.muting
muteStateMutable.value = Success(muting)
if (muting) {
eventHub.dispatch(MuteEvent(accountId))
@ -180,7 +188,7 @@ class ReportViewModel @Inject constructor(
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ relationship ->
val blocking = relationship?.blocking == true
val blocking = relationship.blocking
blockStateMutable.value = Success(blocking)
if (blocking) {
eventHub.dispatch(BlockEvent(accountId))

View file

@ -37,6 +37,7 @@ import com.keylesspalace.tusky.util.setClickableMentions
import com.keylesspalace.tusky.util.setClickableText
import com.keylesspalace.tusky.util.shouldTrimStatus
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.viewdata.StatusViewData
import com.keylesspalace.tusky.viewdata.toViewData
import java.util.Date
@ -45,20 +46,21 @@ class StatusViewHolder(
private val statusDisplayOptions: StatusDisplayOptions,
private val viewState: StatusViewState,
private val adapterHandler: AdapterHandler,
private val getStatusForPosition: (Int) -> Status?
private val getStatusForPosition: (Int) -> StatusViewData.Concrete?
) : RecyclerView.ViewHolder(binding.root) {
private val mediaViewHeight = itemView.context.resources.getDimensionPixelSize(R.dimen.status_media_preview_height)
private val statusViewHelper = StatusViewHelper(itemView)
private val previewListener = object : StatusViewHelper.MediaPreviewListener {
override fun onViewMedia(v: View?, idx: Int) {
status()?.let { status ->
adapterHandler.showMedia(v, status, idx)
viewdata()?.let { viewdata ->
adapterHandler.showMedia(v, viewdata.status, idx)
}
}
override fun onContentHiddenChange(isShowing: Boolean) {
status()?.id?.let { id ->
viewdata()?.id?.let { id ->
viewState.setMediaShow(id, isShowing)
}
}
@ -66,57 +68,57 @@ class StatusViewHolder(
init {
binding.statusSelection.setOnCheckedChangeListener { _, isChecked ->
status()?.let { status ->
adapterHandler.setStatusChecked(status, isChecked)
viewdata()?.let { viewdata ->
adapterHandler.setStatusChecked(viewdata.status, isChecked)
}
}
binding.statusMediaPreviewContainer.clipToOutline = true
}
fun bind(status: Status) {
binding.statusSelection.isChecked = adapterHandler.isStatusChecked(status.id)
fun bind(viewData: StatusViewData.Concrete) {
binding.statusSelection.isChecked = adapterHandler.isStatusChecked(viewData.id)
updateTextView()
val sensitive = status.sensitive
val sensitive = viewData.status.sensitive
statusViewHelper.setMediasPreview(
statusDisplayOptions, status.attachments,
sensitive, previewListener, viewState.isMediaShow(status.id, status.sensitive),
statusDisplayOptions, viewData.status.attachments,
sensitive, previewListener, viewState.isMediaShow(viewData.id, viewData.status.sensitive),
mediaViewHeight
)
statusViewHelper.setupPollReadonly(status.poll.toViewData(), status.emojis, statusDisplayOptions)
setCreatedAt(status.createdAt)
statusViewHelper.setupPollReadonly(viewData.status.poll.toViewData(), viewData.status.emojis, statusDisplayOptions)
setCreatedAt(viewData.status.createdAt)
}
private fun updateTextView() {
status()?.let { status ->
viewdata()?.let { viewdata ->
setupCollapsedState(
shouldTrimStatus(status.content), viewState.isCollapsed(status.id, true),
viewState.isContentShow(status.id, status.sensitive), status.spoilerText
shouldTrimStatus(viewdata.content), viewState.isCollapsed(viewdata.id, true),
viewState.isContentShow(viewdata.id, viewdata.status.sensitive), viewdata.spoilerText
)
if (status.spoilerText.isBlank()) {
setTextVisible(true, status.content, status.mentions, status.tags, status.emojis, adapterHandler)
if (viewdata.spoilerText.isBlank()) {
setTextVisible(true, viewdata.content, viewdata.status.mentions, viewdata.status.tags, viewdata.status.emojis, adapterHandler)
binding.statusContentWarningButton.hide()
binding.statusContentWarningDescription.hide()
} else {
val emojiSpoiler = status.spoilerText.emojify(status.emojis, binding.statusContentWarningDescription, statusDisplayOptions.animateEmojis)
val emojiSpoiler = viewdata.spoilerText.emojify(viewdata.status.emojis, binding.statusContentWarningDescription, statusDisplayOptions.animateEmojis)
binding.statusContentWarningDescription.text = emojiSpoiler
binding.statusContentWarningDescription.show()
binding.statusContentWarningButton.show()
setContentWarningButtonText(viewState.isContentShow(status.id, true))
setContentWarningButtonText(viewState.isContentShow(viewdata.id, true))
binding.statusContentWarningButton.setOnClickListener {
status()?.let { status ->
val contentShown = viewState.isContentShow(status.id, true)
viewdata()?.let { viewdata ->
val contentShown = viewState.isContentShow(viewdata.id, true)
binding.statusContentWarningDescription.invalidate()
viewState.setContentShow(status.id, !contentShown)
setTextVisible(!contentShown, status.content, status.mentions, status.tags, status.emojis, adapterHandler)
viewState.setContentShow(viewdata.id, !contentShown)
setTextVisible(!contentShown, viewdata.content, viewdata.status.mentions, viewdata.status.tags, viewdata.status.emojis, adapterHandler)
setContentWarningButtonText(!contentShown)
}
}
setTextVisible(viewState.isContentShow(status.id, true), status.content, status.mentions, status.tags, status.emojis, adapterHandler)
setTextVisible(viewState.isContentShow(viewdata.id, true), viewdata.content, viewdata.status.mentions, viewdata.status.tags, viewdata.status.emojis, adapterHandler)
}
}
}
@ -169,8 +171,8 @@ class StatusViewHolder(
/* input filter for TextViews have to be set before text */
if (collapsible && (expanded || TextUtils.isEmpty(spoilerText))) {
binding.buttonToggleContent.setOnClickListener {
status()?.let { status ->
viewState.setCollapsed(status.id, !collapsed)
viewdata()?.let { viewdata ->
viewState.setCollapsed(viewdata.id, !collapsed)
updateTextView()
}
}
@ -189,5 +191,5 @@ class StatusViewHolder(
}
}
private fun status() = getStatusForPosition(bindingAdapterPosition)
private fun viewdata() = getStatusForPosition(bindingAdapterPosition)
}

View file

@ -22,16 +22,16 @@ import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.components.report.model.StatusViewState
import com.keylesspalace.tusky.databinding.ItemReportStatusBinding
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.viewdata.StatusViewData
class StatusesAdapter(
private val statusDisplayOptions: StatusDisplayOptions,
private val statusViewState: StatusViewState,
private val adapterHandler: AdapterHandler
) : PagingDataAdapter<Status, StatusViewHolder>(STATUS_COMPARATOR) {
) : PagingDataAdapter<StatusViewData.Concrete, StatusViewHolder>(STATUS_COMPARATOR) {
private val statusForPosition: (Int) -> Status? = { position: Int ->
private val statusForPosition: (Int) -> StatusViewData.Concrete? = { position: Int ->
if (position != RecyclerView.NO_POSITION) getItem(position) else null
}
@ -50,11 +50,11 @@ class StatusesAdapter(
}
companion object {
val STATUS_COMPARATOR = object : DiffUtil.ItemCallback<Status>() {
override fun areContentsTheSame(oldItem: Status, newItem: Status): Boolean =
val STATUS_COMPARATOR = object : DiffUtil.ItemCallback<StatusViewData.Concrete>() {
override fun areContentsTheSame(oldItem: StatusViewData.Concrete, newItem: StatusViewData.Concrete): Boolean =
oldItem == newItem
override fun areItemsTheSame(oldItem: Status, newItem: Status): Boolean =
override fun areItemsTheSame(oldItem: StatusViewData.Concrete, newItem: StatusViewData.Concrete): Boolean =
oldItem.id == newItem.id
}
}

View file

@ -15,9 +15,6 @@
package com.keylesspalace.tusky.components.timeline
import android.text.SpannedString
import androidx.core.text.parseAsHtml
import androidx.core.text.toHtml
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.keylesspalace.tusky.db.TimelineAccountEntity
@ -29,8 +26,6 @@ import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.util.shouldTrimStatus
import com.keylesspalace.tusky.util.trimTrailingWhitespace
import com.keylesspalace.tusky.viewdata.StatusViewData
import java.util.Date
@ -119,7 +114,7 @@ fun Status.toEntity(
authorServerId = actionableStatus.account.id,
inReplyToId = actionableStatus.inReplyToId,
inReplyToAccountId = actionableStatus.inReplyToAccountId,
content = actionableStatus.content.toHtml(),
content = actionableStatus.content,
createdAt = actionableStatus.createdAt.time,
emojis = actionableStatus.emojis.let(gson::toJson),
reblogsCount = actionableStatus.reblogsCount,
@ -165,8 +160,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
inReplyToId = status.inReplyToId,
inReplyToAccountId = status.inReplyToAccountId,
reblog = null,
content = status.content?.parseAsHtml()?.trimTrailingWhitespace()
?: SpannedString(""),
content = status.content.orEmpty(),
createdAt = Date(status.createdAt),
emojis = emojis,
reblogsCount = status.reblogsCount,
@ -195,7 +189,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
inReplyToId = null,
inReplyToAccountId = null,
reblog = reblog,
content = SpannedString(""),
content = "",
createdAt = Date(status.createdAt), // lie but whatever?
emojis = listOf(),
reblogsCount = 0,
@ -223,8 +217,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
inReplyToId = status.inReplyToId,
inReplyToAccountId = status.inReplyToAccountId,
reblog = null,
content = status.content?.parseAsHtml()?.trimTrailingWhitespace()
?: SpannedString(""),
content = status.content.orEmpty(),
createdAt = Date(status.createdAt),
emojis = emojis,
reblogsCount = status.reblogsCount,
@ -249,7 +242,6 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
status = status,
isExpanded = this.status.expanded,
isShowingContent = this.status.contentShowing,
isCollapsible = shouldTrimStatus(status.content),
isCollapsed = this.status.contentCollapsed
)
}

View file

@ -42,7 +42,10 @@ import com.keylesspalace.tusky.network.FilterModel
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.TimelineCases
import com.keylesspalace.tusky.viewdata.StatusViewData
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await
@ -79,15 +82,13 @@ class CachedTimelineViewModel @Inject constructor(
}
).flow
.map { pagingData ->
pagingData.map { timelineStatus ->
pagingData.map(Dispatchers.Default.asExecutor()) { timelineStatus ->
timelineStatus.toViewData(gson)
}
}
.map { pagingData ->
pagingData.filter { statusViewData ->
}.filter(Dispatchers.Default.asExecutor()) { statusViewData ->
!shouldFilterStatus(statusViewData)
}
}
.flowOn(Dispatchers.Default)
.cachedIn(viewModelScope)
init {

View file

@ -40,6 +40,9 @@ import com.keylesspalace.tusky.util.isLessThan
import com.keylesspalace.tusky.util.isLessThanOrEqual
import com.keylesspalace.tusky.util.toViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await
@ -79,10 +82,11 @@ class NetworkTimelineViewModel @Inject constructor(
remoteMediator = NetworkTimelineRemoteMediator(accountManager, this)
).flow
.map { pagingData ->
pagingData.filter { statusViewData ->
pagingData.filter(Dispatchers.Default.asExecutor()) { statusViewData ->
!shouldFilterStatus(statusViewData)
}
}
.flowOn(Dispatchers.Default)
.cachedIn(viewModelScope)
override fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) {