Timeline refactor (#2175)

* Move Timeline files into their own package

* Introduce TimelineViewModel, add coroutines

* Simplify StatusViewData

* Handle timeilne fetch errors

* Rework filters, fix ViewThreadFragment

* Fix NotificationsFragment

* Simplify Notifications and Thread, handle pin

* Redo loading in TimelineViewModel

* Improve error handling in TimelineViewModel

* Rewrite actions in TimelineViewModel

* Apply feedback after timeline factoring review

* Handle initial failure in timeline correctly
This commit is contained in:
Ivan Kupalov 2021-06-11 20:15:40 +02:00 committed by GitHub
commit 44a5b42cac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
58 changed files with 3956 additions and 3618 deletions

View file

@ -40,6 +40,7 @@ import com.keylesspalace.tusky.interfaces.LinkListener;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.List;
public class LinkHelper {
public static String getDomain(String urlString) {
@ -69,7 +70,7 @@ public class LinkHelper {
* @param listener to notify about particular spans that are clicked
*/
public static void setClickableText(TextView view, CharSequence content,
@Nullable Status.Mention[] mentions, final LinkListener listener) {
@Nullable List<Status.Mention> mentions, final LinkListener listener) {
SpannableStringBuilder builder = SpannableStringBuilder.valueOf(content);
URLSpan[] urlSpans = builder.getSpans(0, content.length(), URLSpan.class);
for (URLSpan span : urlSpans) {
@ -85,7 +86,7 @@ public class LinkHelper {
@Override
public void onClick(@NonNull View widget) { listener.onViewTag(tag); }
};
} else if (text.charAt(0) == '@' && mentions != null && mentions.length > 0) {
} else if (text.charAt(0) == '@' && mentions != null && mentions.size() > 0) {
String accountUsername = text.subSequence(1, text.length()).toString();
/* There may be multiple matches for users on different instances with the same
* username. If a match has the same domain we know it's for sure the same, but if
@ -141,8 +142,8 @@ public class LinkHelper {
* @param listener to notify about particular spans that are clicked
*/
public static void setClickableMentions(
TextView view, @Nullable Status.Mention[] mentions, final LinkListener listener) {
if (mentions == null || mentions.length == 0) {
TextView view, @Nullable List<Status.Mention> mentions, final LinkListener listener) {
if (mentions == null || mentions.size() == 0) {
view.setText(null);
return;
}

View file

@ -27,9 +27,9 @@ fun interface StatusProvider {
}
class ListStatusAccessibilityDelegate(
private val recyclerView: RecyclerView,
private val statusActionListener: StatusActionListener,
private val statusProvider: StatusProvider
private val recyclerView: RecyclerView,
private val statusActionListener: StatusActionListener,
private val statusProvider: StatusProvider
) : RecyclerViewAccessibilityDelegate(recyclerView) {
private val a11yManager = context.getSystemService(Context.ACCESSIBILITY_SERVICE)
as AccessibilityManager
@ -39,8 +39,10 @@ class ListStatusAccessibilityDelegate(
private val context: Context get() = recyclerView.context
private val itemDelegate = object : RecyclerViewAccessibilityDelegate.ItemDelegate(this) {
override fun onInitializeAccessibilityNodeInfo(host: View,
info: AccessibilityNodeInfoCompat) {
override fun onInitializeAccessibilityNodeInfo(
host: View,
info: AccessibilityNodeInfoCompat
) {
super.onInitializeAccessibilityNodeInfo(host, info)
val pos = recyclerView.getChildAdapterPosition(host)
@ -52,44 +54,51 @@ class ListStatusAccessibilityDelegate(
info.addAction(replyAction)
if (status.rebloggingEnabled) {
info.addAction(if (status.isReblogged) unreblogAction else reblogAction)
val actionable = status.actionable
if (actionable.rebloggingAllowed()) {
info.addAction(if (actionable.reblogged) unreblogAction else reblogAction)
}
info.addAction(if (status.isFavourited) unfavouriteAction else favouriteAction)
info.addAction(if (status.isBookmarked) unbookmarkAction else bookmarkAction)
info.addAction(if (actionable.favourited) unfavouriteAction else favouriteAction)
info.addAction(if (actionable.bookmarked) unbookmarkAction else bookmarkAction)
val mediaActions = intArrayOf(
R.id.action_open_media_1,
R.id.action_open_media_2,
R.id.action_open_media_3,
R.id.action_open_media_4)
val attachmentCount = min(status.attachments.size, MAX_MEDIA_ATTACHMENTS)
R.id.action_open_media_1,
R.id.action_open_media_2,
R.id.action_open_media_3,
R.id.action_open_media_4
)
val attachmentCount = min(actionable.attachments.size, MAX_MEDIA_ATTACHMENTS)
for (i in 0 until attachmentCount) {
info.addAction(AccessibilityActionCompat(
info.addAction(
AccessibilityActionCompat(
mediaActions[i],
context.getString(R.string.action_open_media_n, i + 1)))
context.getString(R.string.action_open_media_n, i + 1)
)
)
}
info.addAction(openProfileAction)
if (getLinks(status).any()) info.addAction(linksAction)
val mentions = status.mentions
if (mentions != null && mentions.isNotEmpty()) info.addAction(mentionsAction)
val mentions = actionable.mentions
if (mentions.isNotEmpty()) info.addAction(mentionsAction)
if (getHashtags(status).any()) info.addAction(hashtagsAction)
if (!status.rebloggedByUsername.isNullOrEmpty()) {
if (!status.status.reblog?.account?.username.isNullOrEmpty()) {
info.addAction(openRebloggerAction)
}
if (status.reblogsCount > 0) info.addAction(openRebloggedByAction)
if (status.favouritesCount > 0) info.addAction(openFavsAction)
if (actionable.reblogsCount > 0) info.addAction(openRebloggedByAction)
if (actionable.favouritesCount > 0) info.addAction(openFavsAction)
info.addAction(moreAction)
}
}
override fun performAccessibilityAction(host: View, action: Int,
args: Bundle?): Boolean {
override fun performAccessibilityAction(
host: View, action: Int,
args: Bundle?
): Boolean {
val pos = recyclerView.getChildAdapterPosition(host)
when (action) {
R.id.action_reply -> {
@ -105,7 +114,8 @@ class ListStatusAccessibilityDelegate(
R.id.action_open_profile -> {
interrupt()
statusActionListener.onViewAccount(
(statusProvider.getStatus(pos) as StatusViewData.Concrete).senderId)
(statusProvider.getStatus(pos) as StatusViewData.Concrete).actionable.account.id
)
}
R.id.action_open_media_1 -> {
interrupt()
@ -166,43 +176,51 @@ class ListStatusAccessibilityDelegate(
val links = getLinks(status).toList()
val textLinks = links.map { item -> item.link }
AlertDialog.Builder(host.context)
.setTitle(R.string.title_links_dialog)
.setAdapter(ArrayAdapter(
host.context,
android.R.layout.simple_list_item_1,
textLinks)
) { _, which -> LinkHelper.openLink(links[which].link, host.context) }
.show()
.let { forceFocus(it.listView) }
.setTitle(R.string.title_links_dialog)
.setAdapter(
ArrayAdapter(
host.context,
android.R.layout.simple_list_item_1,
textLinks
)
) { _, which -> LinkHelper.openLink(links[which].link, host.context) }
.show()
.let { forceFocus(it.listView) }
}
private fun showMentionsDialog(host: View) {
val status = getStatus(host) as? StatusViewData.Concrete ?: return
val mentions = status.mentions ?: return
val mentions = status.actionable.mentions
val stringMentions = mentions.map { it.username }
AlertDialog.Builder(host.context)
.setTitle(R.string.title_mentions_dialog)
.setAdapter(ArrayAdapter<CharSequence>(host.context,
android.R.layout.simple_list_item_1, stringMentions)
) { _, which ->
statusActionListener.onViewAccount(mentions[which].id)
}
.show()
.let { forceFocus(it.listView) }
.setTitle(R.string.title_mentions_dialog)
.setAdapter(
ArrayAdapter<CharSequence>(
host.context,
android.R.layout.simple_list_item_1, stringMentions
)
) { _, which ->
statusActionListener.onViewAccount(mentions[which].id)
}
.show()
.let { forceFocus(it.listView) }
}
private fun showHashtagsDialog(host: View) {
val status = getStatus(host) as? StatusViewData.Concrete ?: return
val tags = getHashtags(status).map { it.subSequence(1, it.length) }.toList()
AlertDialog.Builder(host.context)
.setTitle(R.string.title_hashtags_dialog)
.setAdapter(ArrayAdapter(host.context,
android.R.layout.simple_list_item_1, tags)
) { _, which ->
statusActionListener.onViewTag(tags[which].toString())
}
.show()
.let { forceFocus(it.listView) }
.setTitle(R.string.title_hashtags_dialog)
.setAdapter(
ArrayAdapter(
host.context,
android.R.layout.simple_list_item_1, tags
)
) { _, which ->
statusActionListener.onViewTag(tags[which].toString())
}
.show()
.let { forceFocus(it.listView) }
}
private fun getStatus(childView: View): StatusViewData {
@ -215,14 +233,15 @@ class ListStatusAccessibilityDelegate(
val content = status.content
return if (content is Spannable) {
content.getSpans(0, content.length, URLSpan::class.java)
.asSequence()
.map { span ->
val text = content.subSequence(
content.getSpanStart(span),
content.getSpanEnd(span))
if (isHashtag(text)) null else LinkSpanInfo(text.toString(), span.url)
}
.filterNotNull()
.asSequence()
.map { span ->
val text = content.subSequence(
content.getSpanStart(span),
content.getSpanEnd(span)
)
if (isHashtag(text)) null else LinkSpanInfo(text.toString(), span.url)
}
.filterNotNull()
} else {
emptySequence()
}
@ -231,11 +250,11 @@ class ListStatusAccessibilityDelegate(
private fun getHashtags(status: StatusViewData.Concrete): Sequence<CharSequence> {
val content = status.content
return content.getSpans(0, content.length, Object::class.java)
.asSequence()
.map { span ->
content.subSequence(content.getSpanStart(span), content.getSpanEnd(span))
}
.filter(this::isHashtag)
.asSequence()
.map { span ->
content.subSequence(content.getSpanStart(span), content.getSpanEnd(span))
}
.filter(this::isHashtag)
}
private fun forceFocus(host: View) {
@ -253,72 +272,88 @@ class ListStatusAccessibilityDelegate(
private fun isHashtag(text: CharSequence) = text.startsWith("#")
private val collapseCwAction = AccessibilityActionCompat(
R.id.action_collapse_cw,
context.getString(R.string.status_content_warning_show_less))
R.id.action_collapse_cw,
context.getString(R.string.status_content_warning_show_less)
)
private val expandCwAction = AccessibilityActionCompat(
R.id.action_expand_cw,
context.getString(R.string.status_content_warning_show_more))
R.id.action_expand_cw,
context.getString(R.string.status_content_warning_show_more)
)
private val replyAction = AccessibilityActionCompat(
R.id.action_reply,
context.getString(R.string.action_reply))
R.id.action_reply,
context.getString(R.string.action_reply)
)
private val unreblogAction = AccessibilityActionCompat(
R.id.action_unreblog,
context.getString(R.string.action_unreblog))
R.id.action_unreblog,
context.getString(R.string.action_unreblog)
)
private val reblogAction = AccessibilityActionCompat(
R.id.action_reblog,
context.getString(R.string.action_reblog))
R.id.action_reblog,
context.getString(R.string.action_reblog)
)
private val unfavouriteAction = AccessibilityActionCompat(
R.id.action_unfavourite,
context.getString(R.string.action_unfavourite))
R.id.action_unfavourite,
context.getString(R.string.action_unfavourite)
)
private val favouriteAction = AccessibilityActionCompat(
R.id.action_favourite,
context.getString(R.string.action_favourite))
R.id.action_favourite,
context.getString(R.string.action_favourite)
)
private val bookmarkAction = AccessibilityActionCompat(
R.id.action_bookmark,
context.getString(R.string.action_bookmark))
R.id.action_bookmark,
context.getString(R.string.action_bookmark)
)
private val unbookmarkAction = AccessibilityActionCompat(
R.id.action_unbookmark,
context.getString(R.string.action_bookmark))
R.id.action_unbookmark,
context.getString(R.string.action_bookmark)
)
private val openProfileAction = AccessibilityActionCompat(
R.id.action_open_profile,
context.getString(R.string.action_view_profile))
R.id.action_open_profile,
context.getString(R.string.action_view_profile)
)
private val linksAction = AccessibilityActionCompat(
R.id.action_links,
context.getString(R.string.action_links))
R.id.action_links,
context.getString(R.string.action_links)
)
private val mentionsAction = AccessibilityActionCompat(
R.id.action_mentions,
context.getString(R.string.action_mentions))
R.id.action_mentions,
context.getString(R.string.action_mentions)
)
private val hashtagsAction = AccessibilityActionCompat(
R.id.action_hashtags,
context.getString(R.string.action_hashtags))
R.id.action_hashtags,
context.getString(R.string.action_hashtags)
)
private val openRebloggerAction = AccessibilityActionCompat(
R.id.action_open_reblogger,
context.getString(R.string.action_open_reblogger))
R.id.action_open_reblogger,
context.getString(R.string.action_open_reblogger)
)
private val openRebloggedByAction = AccessibilityActionCompat(
R.id.action_open_reblogged_by,
context.getString(R.string.action_open_reblogged_by))
R.id.action_open_reblogged_by,
context.getString(R.string.action_open_reblogged_by)
)
private val openFavsAction = AccessibilityActionCompat(
R.id.action_open_faved_by,
context.getString(R.string.action_open_faved_by))
R.id.action_open_faved_by,
context.getString(R.string.action_open_faved_by)
)
private val moreAction = AccessibilityActionCompat(
R.id.action_more,
context.getString(R.string.action_more)
R.id.action_more,
context.getString(R.string.action_more)
)
private data class LinkSpanInfo(val text: String, val link: String)

View file

@ -52,4 +52,8 @@ inline fun <T> List<T>.replacedFirstWhich(replacement: T, predicate: (T) -> Bool
newList[index] = replacement
}
return newList
}
inline fun <reified R> Iterable<*>.firstIsInstanceOrNull(): R? {
return firstOrNull { it is R }?.let { it as R }
}

View file

@ -73,6 +73,15 @@ fun String.isLessThan(other: String): Boolean {
}
}
fun String.idCompareTo(other: String): Int {
return when {
this === other -> 0
this.length < other.length -> -1
this.length > other.length -> 1
else -> this.compareTo(other)
}
}
fun Spanned.trimTrailingWhitespace(): Spanned {
var i = length
do {

View file

@ -1,86 +0,0 @@
/* Copyright 2017 Andrew Dawson
*
* 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.util;
import androidx.annotation.Nullable;
import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.viewdata.NotificationViewData;
import com.keylesspalace.tusky.viewdata.StatusViewData;
/**
* Created by charlag on 12/07/2017.
*/
public final class ViewDataUtils {
@Nullable
public static StatusViewData.Concrete statusToViewData(@Nullable Status status,
boolean alwaysShowSensitiveMedia,
boolean alwaysOpenSpoiler) {
if (status == null) return null;
Status visibleStatus = status.getReblog() == null ? status : status.getReblog();
return new StatusViewData.Builder().setId(status.getId())
.setAttachments(visibleStatus.getAttachments())
.setAvatar(visibleStatus.getAccount().getAvatar())
.setContent(visibleStatus.getContent())
.setCreatedAt(visibleStatus.getCreatedAt())
.setReblogsCount(visibleStatus.getReblogsCount())
.setFavouritesCount(visibleStatus.getFavouritesCount())
.setInReplyToId(visibleStatus.getInReplyToId())
.setFavourited(visibleStatus.getFavourited())
.setBookmarked(visibleStatus.getBookmarked())
.setReblogged(visibleStatus.getReblogged())
.setIsExpanded(alwaysOpenSpoiler)
.setIsShowingSensitiveContent(false)
.setMentions(visibleStatus.getMentions())
.setNickname(visibleStatus.getAccount().getUsername())
.setRebloggedAvatar(status.getReblog() == null ? null : status.getAccount().getAvatar())
.setSensitive(visibleStatus.getSensitive())
.setIsShowingSensitiveContent(alwaysShowSensitiveMedia || !visibleStatus.getSensitive())
.setSpoilerText(visibleStatus.getSpoilerText())
.setRebloggedByUsername(status.getReblog() == null ? null : status.getAccount().getName())
.setUserFullName(visibleStatus.getAccount().getName())
.setVisibility(visibleStatus.getVisibility())
.setSenderId(visibleStatus.getAccount().getId())
.setRebloggingEnabled(visibleStatus.rebloggingAllowed())
.setApplication(visibleStatus.getApplication())
.setStatusEmojis(visibleStatus.getEmojis())
.setAccountEmojis(visibleStatus.getAccount().getEmojis())
.setRebloggedByEmojis(status.getReblog() == null ? null : status.getAccount().getEmojis())
.setCollapsible(SmartLengthInputFilterKt.shouldTrimStatus(visibleStatus.getContent()))
.setCollapsed(true)
.setPoll(visibleStatus.getPoll())
.setCard(visibleStatus.getCard())
.setIsBot(visibleStatus.getAccount().getBot())
.createStatusViewData();
}
public static NotificationViewData.Concrete notificationToViewData(Notification notification,
boolean alwaysShowSensitiveData,
boolean alwaysOpenSpoiler) {
return new NotificationViewData.Concrete(
notification.getType(),
notification.getId(),
notification.getAccount(),
statusToViewData(
notification.getStatus(),
alwaysShowSensitiveData,
alwaysOpenSpoiler
)
);
}
}

View file

@ -0,0 +1,53 @@
@file:JvmName("ViewDataUtils")
/* Copyright 2017 Andrew Dawson
*
* 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.util
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.viewdata.NotificationViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
import com.keylesspalace.tusky.viewdata.toViewData
import java.util.*
@JvmName("statusToViewData")
fun Status.toViewData(
alwaysShowSensitiveMedia: Boolean,
alwaysOpenSpoiler: Boolean
): StatusViewData.Concrete {
val visibleStatus = this.reblog ?: this
return StatusViewData.Concrete(
status = this,
isShowingContent = alwaysShowSensitiveMedia || !visibleStatus.sensitive,
isCollapsible = shouldTrimStatus(visibleStatus.content),
isCollapsed = false,
isExpanded = alwaysOpenSpoiler,
)
}
@JvmName("notificationToViewData")
fun Notification.toViewData(
alwaysShowSensitiveData: Boolean,
alwaysOpenSpoiler: Boolean
): NotificationViewData.Concrete {
return NotificationViewData.Concrete(
this.type,
this.id,
this.account,
this.status?.toViewData(alwaysShowSensitiveData, alwaysOpenSpoiler)
)
}