Use tags from status when adding handlers to hashtag spans in status content (#2344)

* Migrate LinkHelper to kotlin

* Support tags field on statuses

* Use embedded tags list in status instead of text scraping to embed tag click handler.
Fixes #2283

* Make mentions and tags lists nonnullable

* Make LinkHelper.openLink a Context extension method

* Use builtin extension for uri conversion

* More cleanup in LinkHelper

* Add tests for LinkHelper.getDomain

* Unbreak tags in places that don't have a tag list (e.g. profiles)

* Fixup javadoc
This commit is contained in:
Levi Bard 2022-02-25 18:56:21 +01:00 committed by GitHub
commit addce87eb6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 1294 additions and 296 deletions

View file

@ -71,13 +71,15 @@ import com.keylesspalace.tusky.interfaces.ReselectableFragment
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.DefaultTextWatcher
import com.keylesspalace.tusky.util.Error
import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.ThemeUtils
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.setClickableText
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
@ -409,7 +411,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
binding.accountDisplayNameTextView.text = account.name.emojify(account.emojis, binding.accountDisplayNameTextView, animateEmojis)
val emojifiedNote = account.note.emojify(account.emojis, binding.accountNoteTextView, animateEmojis)
LinkHelper.setClickableText(binding.accountNoteTextView, emojifiedNote, null, this)
setClickableText(binding.accountNoteTextView, emojifiedNote, emptyList(), null, this)
// accountFieldAdapter.fields = account.fields ?: emptyList()
accountFieldAdapter.emojis = account.emojis ?: emptyList()
@ -517,7 +519,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
if (account.isRemote()) {
binding.accountRemoveView.show()
binding.accountRemoveView.setOnClickListener {
LinkHelper.openLink(account.url, this)
openLink(account.url)
}
}
}
@ -714,7 +716,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
if (loadedAccount != null) {
val muteDomain = menu.findItem(R.id.action_mute_domain)
domain = LinkHelper.getDomain(loadedAccount?.url)
domain = getDomain(loadedAccount?.url)
if (domain.isEmpty()) {
// If we can't get the domain, there's no way we can mute it anyway...
menu.removeItem(R.id.action_mute_domain)
@ -834,8 +836,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
when (item.itemId) {
R.id.action_open_in_web -> {
// If the account isn't loaded yet, eat the input.
if (loadedAccount != null) {
LinkHelper.openLink(loadedAccount?.url, this)
if (loadedAccount?.url != null) {
openLink(loadedAccount!!.url)
}
return true
}

View file

@ -27,8 +27,9 @@ import com.keylesspalace.tusky.entity.IdentityProof
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.Either
import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.createClickableText
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.setClickableText
class AccountFieldAdapter(
private val linkListener: LinkListener,
@ -54,7 +55,7 @@ class AccountFieldAdapter(
val identityProof = proofOrField.asLeft()
nameTextView.text = identityProof.provider
valueTextView.text = LinkHelper.createClickableText(identityProof.username, identityProof.profileUrl)
valueTextView.text = createClickableText(identityProof.username, identityProof.profileUrl)
valueTextView.movementMethod = LinkMovementMethod.getInstance()
@ -65,7 +66,7 @@ class AccountFieldAdapter(
nameTextView.text = emojifiedName
val emojifiedValue = field.value.emojify(emojis, valueTextView, animateEmojis)
LinkHelper.setClickableText(valueTextView, emojifiedValue, null, linkListener)
setClickableText(valueTextView, emojifiedValue, emptyList(), null, linkListener)
if (field.verifiedAt != null) {
valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0)

View file

@ -37,9 +37,9 @@ import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.interfaces.RefreshableFragment
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.openLink
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.view.SquareImageView
@ -252,7 +252,7 @@ class AccountMediaFragment : Fragment(R.layout.fragment_timeline), RefreshableFr
}
}
Attachment.Type.UNKNOWN -> {
LinkHelper.openLink(items[currentIndex].attachment.url, context)
context?.openLink(items[currentIndex].attachment.url)
}
}
}

View file

@ -31,8 +31,8 @@ import com.keylesspalace.tusky.entity.Announcement
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.EmojiSpan
import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.setClickableText
import java.lang.ref.WeakReference
interface AnnouncementActionListener : LinkListener {
@ -62,7 +62,7 @@ class AnnouncementAdapter(
val emojifiedText: CharSequence = item.content.emojify(item.emojis, text, animateEmojis)
LinkHelper.setClickableText(text, emojifiedText, item.mentions, listener)
setClickableText(text, emojifiedText, item.mentions, item.tags, listener)
// If wellbeing mode is enabled, announcement badge counts should not be shown.
if (wellbeingEnabled) {

View file

@ -25,6 +25,7 @@ import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Conversation
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.util.shouldTrimStatus
@ -79,6 +80,7 @@ data class ConversationStatusEntity(
val spoilerText: String,
val attachments: ArrayList<Attachment>,
val mentions: List<Status.Mention>,
val tags: List<HashTag>,
val showingHiddenContent: Boolean,
val expanded: Boolean,
val collapsible: Boolean,
@ -107,6 +109,7 @@ data class ConversationStatusEntity(
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
@ -132,6 +135,7 @@ data class ConversationStatusEntity(
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()
@ -162,6 +166,7 @@ data class ConversationStatusEntity(
visibility = Status.Visibility.DIRECT,
attachments = attachments,
mentions = mentions,
tags = tags,
application = null,
pinned = false,
muted = muted,
@ -197,6 +202,7 @@ fun Status.toEntity() =
spoilerText = spoilerText,
attachments = attachments,
mentions = mentions,
tags = tags,
showingHiddenContent = false,
expanded = false,
collapsible = shouldTrimStatus(content),

View file

@ -108,7 +108,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
statusDisplayOptions);
setSpoilerAndContent(status.getExpanded(), status.getContent(), status.getSpoilerText(),
status.getMentions(), status.getEmojis(),
status.getMentions(), status.getTags(), status.getEmojis(),
PollViewDataKt.toViewData(status.getPoll()), statusDisplayOptions, listener);
setConversationName(conversation.getAccounts());

View file

@ -23,9 +23,9 @@ import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.report.model.StatusViewState
import com.keylesspalace.tusky.databinding.ItemReportStatusBinding
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.StatusViewHelper
import com.keylesspalace.tusky.util.StatusViewHelper.Companion.COLLAPSE_INPUT_FILTER
@ -33,6 +33,8 @@ import com.keylesspalace.tusky.util.StatusViewHelper.Companion.NO_INPUT_FILTER
import com.keylesspalace.tusky.util.TimestampUtils
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.hide
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.toViewData
@ -96,7 +98,7 @@ class StatusViewHolder(
)
if (status.spoilerText.isBlank()) {
setTextVisible(true, status.content, status.mentions, status.emojis, adapterHandler)
setTextVisible(true, status.content, status.mentions, status.tags, status.emojis, adapterHandler)
binding.statusContentWarningButton.hide()
binding.statusContentWarningDescription.hide()
} else {
@ -110,11 +112,11 @@ class StatusViewHolder(
val contentShown = viewState.isContentShow(status.id, true)
binding.statusContentWarningDescription.invalidate()
viewState.setContentShow(status.id, !contentShown)
setTextVisible(!contentShown, status.content, status.mentions, status.emojis, adapterHandler)
setTextVisible(!contentShown, status.content, status.mentions, status.tags, status.emojis, adapterHandler)
setContentWarningButtonText(!contentShown)
}
}
setTextVisible(viewState.isContentShow(status.id, true), status.content, status.mentions, status.emojis, adapterHandler)
setTextVisible(viewState.isContentShow(status.id, true), status.content, status.mentions, status.tags, status.emojis, adapterHandler)
}
}
}
@ -130,15 +132,16 @@ class StatusViewHolder(
private fun setTextVisible(
expanded: Boolean,
content: Spanned,
mentions: List<Status.Mention>?,
mentions: List<Status.Mention>,
tags: List<HashTag>?,
emojis: List<Emoji>,
listener: LinkListener
) {
if (expanded) {
val emojifiedText = content.emojify(emojis, binding.statusContent, statusDisplayOptions.animateEmojis)
LinkHelper.setClickableText(binding.statusContent, emojifiedText, mentions, listener)
setClickableText(binding.statusContent, emojifiedText, mentions, tags, listener)
} else {
LinkHelper.setClickableMentions(binding.statusContent, mentions, listener)
setClickableMentions(binding.statusContent, mentions, listener)
}
if (binding.statusContent.text.isNullOrBlank()) {
binding.statusContent.hide()

View file

@ -54,8 +54,8 @@ import com.keylesspalace.tusky.interfaces.AccountSelectionListener
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.CardViewMode
import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.openLink
import com.keylesspalace.tusky.view.showMuteAccountDialog
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
@ -142,7 +142,7 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
}
}
Attachment.Type.UNKNOWN -> {
LinkHelper.openLink(actionable.attachments[attachmentIndex].url, context)
context?.openLink(actionable.attachments[attachmentIndex].url)
}
}
}

View file

@ -26,6 +26,7 @@ import com.keylesspalace.tusky.db.TimelineStatusWithAccount
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.util.shouldTrimStatus
@ -41,6 +42,7 @@ data class Placeholder(
private val attachmentArrayListType = object : TypeToken<ArrayList<Attachment>>() {}.type
private val emojisListType = object : TypeToken<List<Emoji>>() {}.type
private val mentionListType = object : TypeToken<List<Status.Mention>>() {}.type
private val tagListType = object : TypeToken<List<HashTag>>() {}.type
fun Account.toEntity(accountId: Long, gson: Gson): TimelineAccountEntity {
return TimelineAccountEntity(
@ -99,6 +101,7 @@ fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity {
visibility = Status.Visibility.UNKNOWN,
attachments = null,
mentions = null,
tags = null,
application = null,
reblogServerId = null,
reblogAccountId = null,
@ -138,6 +141,7 @@ fun Status.toEntity(
visibility = actionableStatus.visibility,
attachments = actionableStatus.attachments.let(gson::toJson),
mentions = actionableStatus.mentions.let(gson::toJson),
tags = actionableStatus.tags.let(gson::toJson),
application = actionableStatus.application.let(gson::toJson),
reblogServerId = reblog?.id,
reblogAccountId = reblog?.let { this.account.id },
@ -157,6 +161,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
val attachments: ArrayList<Attachment> = gson.fromJson(status.attachments, attachmentArrayListType) ?: arrayListOf()
val mentions: List<Status.Mention> = gson.fromJson(status.mentions, mentionListType) ?: emptyList()
val tags: List<HashTag> = gson.fromJson(status.tags, tagListType) ?: emptyList()
val application = gson.fromJson(status.application, Status.Application::class.java)
val emojis: List<Emoji> = gson.fromJson(status.emojis, emojisListType) ?: emptyList()
val poll: Poll? = gson.fromJson(status.poll, Poll::class.java)
@ -183,6 +188,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
visibility = status.visibility,
attachments = attachments,
mentions = mentions,
tags = tags,
application = application,
pinned = false,
muted = status.muted,
@ -211,6 +217,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
visibility = status.visibility,
attachments = ArrayList(),
mentions = listOf(),
tags = listOf(),
application = null,
pinned = status.pinned,
muted = status.muted,
@ -239,6 +246,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
visibility = status.visibility,
attachments = attachments,
mentions = mentions,
tags = tags,
application = application,
pinned = status.pinned,
muted = status.muted,

View file

@ -34,7 +34,7 @@ import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.FilterModel
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.TimelineCases
import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.getDomain
import com.keylesspalace.tusky.util.inc
import com.keylesspalace.tusky.util.toViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
@ -117,7 +117,7 @@ class NetworkTimelineViewModel @Inject constructor(
override fun removeAllByInstance(instance: String) {
statusData.removeAll { vd ->
val status = vd.asStatusOrNull()?.status ?: return@removeAll false
LinkHelper.getDomain(status.account.url) == instance
getDomain(status.account.url) == instance
}
currentSource?.invalidate()
}