diff --git a/app/src/main/java/com/nextcloud/utils/text/Spans.java b/app/src/main/java/com/nextcloud/utils/text/Spans.java new file mode 100644 index 000000000000..098366292ccb --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/text/Spans.java @@ -0,0 +1,82 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2021 Andy Scherzinger + * SPDX-FileCopyrightText: 2017-2018 Mario Danic + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.utils.text; + +import android.graphics.drawable.Drawable; + +import androidx.annotation.NonNull; +import thirdparties.fresco.BetterImageSpan; + +public class Spans { + + public static class MentionChipSpan extends BetterImageSpan { + public String id; + public CharSequence label; + + public MentionChipSpan(@NonNull Drawable drawable, int verticalAlignment, String id, CharSequence label) { + super(drawable, verticalAlignment); + this.id = id; + this.label = label; + } + + public String getId() { + return this.id; + } + + public CharSequence getLabel() { + return this.label; + } + + public void setId(String id) { + this.id = id; + } + + public void setLabel(CharSequence label) { + this.label = label; + } + + public boolean equals(final Object o) { + if (o == this) { + return true; + } + if (!(o instanceof MentionChipSpan)) { + return false; + } + final MentionChipSpan other = (MentionChipSpan) o; + if (!other.canEqual((Object) this)) { + return false; + } + final Object this$id = this.getId(); + final Object other$id = other.getId(); + if (this$id == null ? other$id != null : !this$id.equals(other$id)) { + return false; + } + final Object this$label = this.getLabel(); + final Object other$label = other.getLabel(); + + return this$label == null ? other$label == null : this$label.equals(other$label); + } + + protected boolean canEqual(final Object other) { + return other instanceof MentionChipSpan; + } + + public int hashCode() { + final int PRIME = 59; + int result = 1; + final Object $id = this.getId(); + result = result * PRIME + ($id == null ? 43 : $id.hashCode()); + final Object $label = this.getLabel(); + return result * PRIME + ($label == null ? 43 : $label.hashCode()); + } + + public String toString() { + return "Spans.MentionChipSpan(id=" + this.getId() + ", label=" + this.getLabel() + ")"; + } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/NotificationsActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/NotificationsActivity.kt index dc24fb140963..c00bab07244f 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/NotificationsActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/NotificationsActivity.kt @@ -295,7 +295,7 @@ class NotificationsActivity : private fun initializeAdapter() { initializeClient() if (adapter == null) { - adapter = NotificationListAdapter(client, this, viewThemeUtils) + adapter = NotificationListAdapter(client, this, viewThemeUtils, accountManager) binding.list.adapter = adapter } } diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/ActivityListAdapter.java b/app/src/main/java/com/owncloud/android/ui/adapter/ActivityListAdapter.java index d8246088ae81..678be02bcbac 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/ActivityListAdapter.java +++ b/app/src/main/java/com/owncloud/android/ui/adapter/ActivityListAdapter.java @@ -16,6 +16,7 @@ import android.content.res.Resources; import android.graphics.Color; import android.graphics.PorterDuff; +import android.graphics.drawable.Drawable; import android.os.Handler; import android.os.Looper; import android.text.Spannable; @@ -24,7 +25,6 @@ import android.text.TextUtils; import android.text.format.DateFormat; import android.text.format.DateUtils; -import android.text.method.LinkMovementMethod; import android.text.style.ClickableSpan; import android.text.style.ForegroundColorSpan; import android.text.style.StyleSpan; @@ -35,10 +35,12 @@ import android.widget.LinearLayout; import android.widget.TextView; +import com.google.android.material.chip.ChipDrawable; import com.nextcloud.client.account.CurrentAccountProvider; import com.nextcloud.client.network.ClientFactory; import com.nextcloud.common.NextcloudClient; import com.nextcloud.utils.GlideHelper; +import com.nextcloud.utils.text.Spans; import com.owncloud.android.MainApp; import com.owncloud.android.R; import com.owncloud.android.databinding.ActivityListItemBinding; @@ -61,11 +63,13 @@ import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; +import thirdparties.fresco.BetterImageSpan; /** * Adapter for the activity view. */ -public class ActivityListAdapter extends RecyclerView.Adapter implements StickyHeaderAdapter { +public class ActivityListAdapter extends RecyclerView.Adapter implements StickyHeaderAdapter, + DisplayUtils.AvatarGenerationListener { static final int HEADER_TYPE = 100; static final int ACTIVITY_TYPE = 101; @@ -147,9 +151,8 @@ public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int positi if (!TextUtils.isEmpty(activity.getRichSubjectElement().getRichSubject())) { activityViewHolder.binding.subject.setVisibility(View.VISIBLE); - activityViewHolder.binding.subject.setMovementMethod(LinkMovementMethod.getInstance()); - activityViewHolder.binding.subject.setText(addClickablePart(activity.getRichSubjectElement()), - TextView.BufferType.SPANNABLE); + activityViewHolder.binding.subject.setText(addClickablePart(activity.getRichSubjectElement())); + activityViewHolder.binding.subject.setVisibility(View.VISIBLE); } else if (!TextUtils.isEmpty(activity.getSubject())) { activityViewHolder.binding.subject.setVisibility(View.VISIBLE); @@ -275,6 +278,17 @@ private ImageView createThumbnailNew(PreviewObject previewObject, List { +public class NotificationListAdapter extends RecyclerView.Adapter + implements DisplayUtils.AvatarGenerationListener { private static final String FILE = "file"; private static final String ACTION_TYPE_WEB = "WEB"; private final StyleSpan styleSpanBold = new StyleSpan(Typeface.BOLD); @@ -64,14 +71,17 @@ public class NotificationListAdapter extends RecyclerView.Adapter(); this.client = client; this.notificationsActivity = notificationsActivity; this.viewThemeUtils = viewThemeUtils; + this.currentAccountProvider = currentAccountProvider; foregroundColorSpanBlack = new ForegroundColorSpan( notificationsActivity.getResources().getColor(R.color.text_color)); } @@ -107,12 +117,6 @@ public void onBindViewHolder(@NonNull NotificationViewHolder holder, int positio notification.getLink())); holder.binding.subject.setText(subject); } else { - if (!TextUtils.isEmpty(notification.subjectRich)) { - holder.binding.subject.setText(makeSpecialPartsBold(notification)); - } else { - holder.binding.subject.setText(subject); - } - if (file != null && !TextUtils.isEmpty(file.id)) { holder.binding.subject.setOnClickListener(v -> { Intent intent = new Intent(notificationsActivity, FileDisplayActivity.class); @@ -124,6 +128,12 @@ public void onBindViewHolder(@NonNull NotificationViewHolder holder, int positio } } + if (TextUtils.isEmpty(notification.subjectRich)) { + holder.binding.subject.setText(subject); + } else { + holder.binding.subject.setText(makeSpecialPartsBold(notification)); + } + if (notification.getMessage() != null && !notification.getMessage().isEmpty()) { holder.binding.message.setText(notification.getMessage()); holder.binding.message.setVisibility(View.VISIBLE); @@ -308,6 +318,7 @@ public void setButtons(NotificationViewHolder holder, Notification notification) private SpannableStringBuilder makeSpecialPartsBold(Notification notification) { String text = notification.getSubjectRich(); SpannableStringBuilder ssb = new SpannableStringBuilder(text); + Context context = notificationsActivity; int openingBrace = text.indexOf('{'); int closingBrace; @@ -318,13 +329,38 @@ private SpannableStringBuilder makeSpecialPartsBold(Notification notification) { RichObject richObject = notification.subjectRichParameters.get(replaceablePart); if (richObject != null) { - String name = richObject.getName(); - ssb.replace(openingBrace, closingBrace, name); - text = ssb.toString(); - closingBrace = openingBrace + name.length(); + if ("user".equals(richObject.getType())) { + String name = richObject.getName(); + + ChipDrawable drawableForChip = getDrawableForMentionChipSpan(R.xml.chip_others, name); + + Spans.MentionChipSpan mentionChipSpan = new Spans.MentionChipSpan(drawableForChip, + BetterImageSpan.ALIGN_CENTER, + richObject.id, + name + ); + + DisplayUtils.setAvatar( + currentAccountProvider.getUser(), + richObject.id, + name, + this, + context.getResources().getDimension(R.dimen.standard_padding), + context.getResources(), + drawableForChip, + context + ); + + ssb.setSpan(mentionChipSpan, openingBrace, closingBrace, Spannable.SPAN_INCLUSIVE_EXCLUSIVE); + } else { + String name = richObject.getName(); + ssb.replace(openingBrace, closingBrace, name); + text = ssb.toString(); + closingBrace = openingBrace + name.length(); - ssb.setSpan(styleSpanBold, openingBrace, closingBrace, 0); - ssb.setSpan(foregroundColorSpanBlack, openingBrace, closingBrace, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + ssb.setSpan(styleSpanBold, openingBrace, closingBrace, 0); + ssb.setSpan(foregroundColorSpanBlack, openingBrace, closingBrace, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } } openingBrace = text.indexOf('{', closingBrace); } @@ -332,6 +368,17 @@ private SpannableStringBuilder makeSpecialPartsBold(Notification notification) { return ssb; } + private ChipDrawable getDrawableForMentionChipSpan(int chipResource, String text) { + ChipDrawable chip = ChipDrawable.createFromResource(notificationsActivity, chipResource); + chip.setEllipsize(TextUtils.TruncateAt.MIDDLE); + chip.setLayoutDirection(notificationsActivity.getResources().getConfiguration().getLayoutDirection()); + chip.setText(text); + chip.setChipIconResource(R.drawable.accent_circle); + chip.setBounds(0, 0, chip.getIntrinsicWidth(), chip.getIntrinsicHeight()); + + return chip; + } + public void removeNotification(NotificationViewHolder holder) { int position = holder.getAdapterPosition(); @@ -360,6 +407,16 @@ public int getItemCount() { return notificationsList.size(); } + @Override + public void avatarGenerated(Drawable avatarDrawable, Object callContext) { + ((ChipDrawable) callContext).setChipIcon(avatarDrawable); + } + + @Override + public boolean shouldCallGeneratedCallback(String tag, Object callContext) { + return true; + } + public static class NotificationViewHolder extends RecyclerView.ViewHolder { NotificationListItemBinding binding; diff --git a/app/src/main/java/third_parties/fresco/BetterImageSpan.kt b/app/src/main/java/third_parties/fresco/BetterImageSpan.kt new file mode 100644 index 000000000000..a2f34bab63cf --- /dev/null +++ b/app/src/main/java/third_parties/fresco/BetterImageSpan.kt @@ -0,0 +1,118 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Your Name + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package thirdparties.fresco + +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.drawable.Drawable +import android.text.style.ReplacementSpan +import androidx.annotation.IntDef + +/** + * A better implementation of image spans that also supports centering images against the text. + * + * In order to migrate from ImageSpan, replace `new ImageSpan(drawable, alignment)` with + * `new BetterImageSpan(drawable, BetterImageSpan.normalizeAlignment(alignment))`. + * + * There are 2 main differences between BetterImageSpan and ImageSpan: + * 1. Pass in ALIGN_CENTER to center images against the text. + * 2. ALIGN_BOTTOM no longer unnecessarily increases the size of the text: + * DynamicDrawableSpan (ImageSpan's parent) adjusts sizes as if alignment was ALIGN_BASELINE + * which can lead to unnecessary whitespace. + */ +open class BetterImageSpan @JvmOverloads constructor( + val drawable: Drawable, + @param:BetterImageSpanAlignment private val mAlignment: Int = ALIGN_BASELINE +) : ReplacementSpan() { + @IntDef(*[ALIGN_BASELINE, ALIGN_BOTTOM, ALIGN_CENTER]) + @Retention(AnnotationRetention.SOURCE) + annotation class BetterImageSpanAlignment + + private var mWidth = 0 + private var mHeight = 0 + private var mBounds: Rect? = null + private val mFontMetricsInt = Paint.FontMetricsInt() + + init { + updateBounds() + } + + /** + * Returns the width of the image span and increases the height if font metrics are available. + */ + override fun getSize( + paint: Paint, + text: CharSequence, + start: Int, + end: Int, + fontMetrics: Paint.FontMetricsInt? + ): Int { + updateBounds() + if (fontMetrics == null) { + return mWidth + } + val offsetAbove = getOffsetAboveBaseline(fontMetrics) + val offsetBelow = mHeight + offsetAbove + if (offsetAbove < fontMetrics.ascent) { + fontMetrics.ascent = offsetAbove + } + if (offsetAbove < fontMetrics.top) { + fontMetrics.top = offsetAbove + } + if (offsetBelow > fontMetrics.descent) { + fontMetrics.descent = offsetBelow + } + if (offsetBelow > fontMetrics.bottom) { + fontMetrics.bottom = offsetBelow + } + return mWidth + } + + override fun draw( + canvas: Canvas, + text: CharSequence, + start: Int, + end: Int, + x: Float, + top: Int, + y: Int, + bottom: Int, + paint: Paint + ) { + paint.getFontMetricsInt(mFontMetricsInt) + val iconTop = y + getOffsetAboveBaseline(mFontMetricsInt) + canvas.translate(x, iconTop.toFloat()) + drawable.draw(canvas) + canvas.translate(-x, -iconTop.toFloat()) + } + + private fun updateBounds() { + mBounds = drawable.bounds + mWidth = mBounds!!.width() + mHeight = mBounds!!.height() + } + + private fun getOffsetAboveBaseline(fm: Paint.FontMetricsInt): Int = when (mAlignment) { + ALIGN_BOTTOM -> fm.descent - mHeight + ALIGN_CENTER -> { + val textHeight = fm.descent - fm.ascent + val offset = (textHeight - mHeight) / 2 + fm.ascent + offset + } + + ALIGN_BASELINE -> -mHeight + else -> -mHeight + } + + companion object { + const val ALIGN_BOTTOM = 0 + const val ALIGN_BASELINE = 1 + const val ALIGN_CENTER = 2 + } +} diff --git a/app/src/main/res/drawable/accent_circle.xml b/app/src/main/res/drawable/accent_circle.xml new file mode 100644 index 000000000000..3acd7c33c465 --- /dev/null +++ b/app/src/main/res/drawable/accent_circle.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/layout/activity_list_item.xml b/app/src/main/res/layout/activity_list_item.xml index ad17d9e40485..c4601ca2b5b0 100644 --- a/app/src/main/res/layout/activity_list_item.xml +++ b/app/src/main/res/layout/activity_list_item.xml @@ -32,17 +32,23 @@ android:layout_toEndOf="@id/icon" android:orientation="vertical"> - + + + + @style/Theme.ownCloud.Launcher + + diff --git a/app/src/main/res/xml/chip_others.xml b/app/src/main/res/xml/chip_others.xml new file mode 100644 index 000000000000..9f878b3edb22 --- /dev/null +++ b/app/src/main/res/xml/chip_others.xml @@ -0,0 +1,13 @@ + +