diff --git a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt index 1ce538927..2cdb1db55 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt @@ -16,8 +16,9 @@ package com.keylesspalace.tusky.components.announcements import android.annotation.SuppressLint +import android.graphics.drawable.Drawable import android.os.Build -import android.text.SpannableStringBuilder +import android.text.SpannableString import android.view.ContextThemeWrapper import android.view.LayoutInflater import android.view.View @@ -25,6 +26,7 @@ import android.view.ViewGroup import androidx.core.view.size import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide +import com.bumptech.glide.request.target.Target import com.google.android.material.chip.Chip import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemAnnouncementBinding @@ -33,9 +35,11 @@ import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.util.AbsoluteTimeFormatter import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.EmojiSpan +import com.keylesspalace.tusky.util.clearEmojiTargets import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.util.setClickableText +import com.keylesspalace.tusky.util.setEmojiTargets import com.keylesspalace.tusky.util.visible interface AnnouncementActionListener : LinkListener { @@ -94,6 +98,11 @@ class AnnouncementAdapter( // hide button if announcement badge limit is already reached addReactionChip.visible(item.reactions.size < 8) + val requestManager = Glide.with(chips) + + chips.clearEmojiTargets() + val targets = ArrayList>(item.reactions.size) + item.reactions.forEachIndexed { i, reaction -> ( chips.getChildAt(i)?.takeUnless { it.id == R.id.addReactionChip } as Chip? @@ -109,13 +118,14 @@ class AnnouncementAdapter( } else { // we set the EmojiSpan on a space, because otherwise the Chip won't have the right size // https://github.com/tuskyapp/Tusky/issues/2308 - val spanBuilder = SpannableStringBuilder(" ${reaction.count}") + val spannable = SpannableString(" ${reaction.count}") val span = EmojiSpan(this) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { span.contentDescription = reaction.name } - spanBuilder.setSpan(span, 0, 1, 0) - Glide.with(this) + val target = span.createGlideTarget(this, animateEmojis) + spannable.setSpan(span, 0, 1, 0) + requestManager .asDrawable() .load( if (animateEmojis) { @@ -124,8 +134,9 @@ class AnnouncementAdapter( reaction.staticUrl } ) - .into(span.getTarget(animateEmojis)) - this.text = spanBuilder + .into(target) + targets.add(target) + this.text = spannable } isChecked = reaction.me @@ -144,11 +155,18 @@ class AnnouncementAdapter( chips.removeViewAt(item.reactions.size) } + // Store Glide targets for later cancellation + chips.setEmojiTargets(targets) + addReactionChip.setOnClickListener { listener.openReactionPicker(item.id, it) } } + override fun onViewRecycled(holder: BindingHolder) { + holder.binding.chipGroup.clearEmojiTargets() + } + override fun getItemCount() = items.size fun updateList(items: List) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/ReportNotificationViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/ReportNotificationViewHolder.kt index 082ade3d6..f7c0e7134 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/ReportNotificationViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/ReportNotificationViewHolder.kt @@ -16,15 +16,16 @@ package com.keylesspalace.tusky.components.notifications import android.content.Context +import android.text.TextUtils import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding import com.keylesspalace.tusky.interfaces.AccountActionListener import com.keylesspalace.tusky.util.StatusDisplayOptions -import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.getRelativeTimeSpanString import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.unicodeWrap +import com.keylesspalace.tusky.util.updateEmojiTargets import com.keylesspalace.tusky.viewdata.NotificationViewData class ReportNotificationViewHolder( @@ -41,10 +42,16 @@ class ReportNotificationViewHolder( val report = viewData.report!! val reporter = viewData.account - val reporterName = reporter.name.unicodeWrap().emojify(reporter.emojis, binding.notificationTopText, statusDisplayOptions.animateEmojis) - val reporteeName = report.targetAccount.name.unicodeWrap().emojify(report.targetAccount.emojis, binding.notificationTopText, statusDisplayOptions.animateEmojis) + binding.notificationTopText.updateEmojiTargets { + val reporterName = reporter.name.unicodeWrap().emojify(reporter.emojis, statusDisplayOptions.animateEmojis) + val reporteeName = report.targetAccount.name.unicodeWrap().emojify(report.targetAccount.emojis, statusDisplayOptions.animateEmojis) - binding.notificationTopText.text = itemView.context.getString(R.string.notification_header_report_format, reporterName, reporteeName) + // Context.getString() returns a String and doesn't support Spannable. + // Convert the placeholders to the format used by TextUtils.expandTemplate which does. + val topText = + view.context.getString(R.string.notification_header_report_format, "^1", "^2") + view.text = TextUtils.expandTemplate(topText, reporterName, reporteeName) + } binding.notificationSummary.text = itemView.context.getString(R.string.notification_summary_report_format, getRelativeTimeSpanString(itemView.context, report.createdAt.time, System.currentTimeMillis()), report.statusIds?.size ?: 0) binding.notificationCategory.text = getTranslatedCategory(itemView.context, report.category) diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt index f0c9eafd9..2fbb85f00 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt @@ -42,8 +42,9 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.util.EventLogger import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.PlayerView import com.bumptech.glide.Glide -import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.target.CustomViewTarget import com.bumptech.glide.request.transition.Transition import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BuildConfig @@ -321,22 +322,26 @@ class ViewVideoFragment : ViewMediaFragment() { // Audio-only files might have a preview image. If they do, set it as the artwork if (isAudio) { mediaAttachment.previewUrl?.let { url -> - Glide.with(this).load(url).into(object : CustomTarget() { - @SuppressLint("SyntheticAccessor") - override fun onResourceReady( - resource: Drawable, - transition: Transition? - ) { - view ?: return - binding.videoView.defaultArtwork = resource - } + Glide.with(this) + .load(url) + .into( + object : CustomViewTarget(binding.videoView) { + override fun onLoadFailed(errorDrawable: Drawable?) { + // Don't do anything + } - @SuppressLint("SyntheticAccessor") - override fun onLoadCleared(placeholder: Drawable?) { - view ?: return - binding.videoView.defaultArtwork = null - } - }) + override fun onResourceCleared(placeholder: Drawable?) { + view.defaultArtwork = null + } + + override fun onResourceReady( + resource: Drawable, + transition: Transition? + ) { + view.defaultArtwork = resource + } + }.clearOnDetach() + ) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt index 0ef40b312..53290d075 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt @@ -21,60 +21,97 @@ import android.graphics.Canvas import android.graphics.Paint import android.graphics.drawable.Animatable import android.graphics.drawable.Drawable -import android.text.SpannableStringBuilder import android.text.style.ReplacementSpan import android.view.View import android.widget.TextView +import androidx.core.text.toSpannable import com.bumptech.glide.Glide import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.target.Target import com.bumptech.glide.request.transition.Transition import com.keylesspalace.tusky.R import com.keylesspalace.tusky.entity.Emoji -import java.lang.ref.WeakReference -import java.util.regex.Pattern /** * replaces emoji shortcodes in a text with EmojiSpans * @receiver the text containing custom emojis - * @param emojis a list of the custom emojis (nullable for backward compatibility with old mastodon instances) + * @param emojis a list of the custom emojis * @param view a reference to the a view the emojis will be shown in (should be the TextView, but parents of the TextView are also acceptable) * @return the text with the shortcodes replaced by EmojiSpans */ fun CharSequence.emojify(emojis: List, view: View, animate: Boolean): CharSequence { - if (emojis.isEmpty()) { - return this + return view.updateEmojiTargets { + emojify(emojis, animate) } +} - val builder = SpannableStringBuilder.valueOf(this) +class EmojiTargetScope(val view: T) { + private val _targets = mutableListOf>() + val targets: List> + get() = _targets - emojis.forEach { (shortcode, url, staticUrl) -> - val matcher = Pattern.compile(":$shortcode:", Pattern.LITERAL) - .matcher(this) - - while (matcher.find()) { - val span = EmojiSpan(view) - - builder.setSpan(span, matcher.start(), matcher.end(), 0) - Glide.with(view) - .asDrawable() - .load( - if (animate) { - url - } else { - staticUrl - } - ) - .into(span.getTarget(animate)) + fun CharSequence.emojify(emojis: List, animate: Boolean): CharSequence { + if (emojis.isEmpty()) { + return this } + + val spannable = toSpannable() + val requestManager = Glide.with(view) + + emojis.forEach { (shortcode, url, staticUrl) -> + val pattern = ":$shortcode:" + var start = indexOf(pattern) + + while (start != -1) { + val end = start + pattern.length + val span = EmojiSpan(view) + + spannable.setSpan(span, start, end, 0) + val target = span.createGlideTarget(view, animate) + requestManager + .asDrawable() + .load( + if (animate) { + url + } else { + staticUrl + } + ) + .into(target) + _targets.add(target) + + start = indexOf(pattern, end) + } + } + + return spannable } - return builder +} + +inline fun T.updateEmojiTargets(body: EmojiTargetScope.() -> R): R { + clearEmojiTargets() + val scope = EmojiTargetScope(this) + val result = body(scope) + setEmojiTargets(scope.targets) + return result +} + +@Suppress("UNCHECKED_CAST") +fun View.clearEmojiTargets() { + getTag(R.id.custom_emoji_targets_tag)?.let { tag -> + val targets = tag as List> + val requestManager = Glide.with(this) + targets.forEach { requestManager.clear(it) } + setTag(R.id.custom_emoji_targets_tag, null) + } +} + +fun View.setEmojiTargets(targets: List>) { + setTag(R.id.custom_emoji_targets_tag, targets.takeIf { it.isNotEmpty() }) } class EmojiSpan(view: View) : ReplacementSpan() { - private val viewWeakReference = WeakReference(view) - private val emojiSize: Int = if (view is TextView) { view.paint.textSize } else { @@ -147,34 +184,52 @@ class EmojiSpan(view: View) : ReplacementSpan() { } } - fun getTarget(animate: Boolean): Target { + fun createGlideTarget(view: View, animate: Boolean): Target { return object : CustomTarget(emojiSize, emojiSize) { - override fun onResourceReady(resource: Drawable, transition: Transition?) { - viewWeakReference.get()?.let { view -> - if (animate && resource is Animatable) { - val callback = resource.callback - - resource.callback = object : Drawable.Callback { - override fun unscheduleDrawable(p0: Drawable, p1: Runnable) { - callback?.unscheduleDrawable(p0, p1) - } - override fun scheduleDrawable(p0: Drawable, p1: Runnable, p2: Long) { - callback?.scheduleDrawable(p0, p1, p2) - } - override fun invalidateDrawable(p0: Drawable) { - callback?.invalidateDrawable(p0) - view.invalidate() - } - } - resource.start() - } - - imageDrawable = resource - view.invalidate() - } + override fun onStart() { + (imageDrawable as? Animatable)?.start() } - override fun onLoadCleared(placeholder: Drawable?) {} + override fun onStop() { + (imageDrawable as? Animatable)?.stop() + } + + override fun onLoadFailed(errorDrawable: Drawable?) { + // Nothing to do + } + + override fun onResourceReady(resource: Drawable, transition: Transition?) { + if (animate && resource is Animatable) { + resource.callback = object : Drawable.Callback { + override fun invalidateDrawable(who: Drawable) { + view.invalidate() + } + + override fun scheduleDrawable(who: Drawable, what: Runnable, `when`: Long) { + view.postDelayed(what, `when`) + } + + override fun unscheduleDrawable(who: Drawable, what: Runnable) { + view.removeCallbacks(what) + } + } + resource.start() + } + + imageDrawable = resource + view.invalidate() + } + + override fun onLoadCleared(placeholder: Drawable?) { + imageDrawable?.let { currentDrawable -> + if (currentDrawable is Animatable) { + currentDrawable.stop() + currentDrawable.callback = null + } + } + imageDrawable = null + view.invalidate() + } } } } diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index 55ca7d93f..ba6866c48 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -1,4 +1,5 @@ + \ No newline at end of file