Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 039cc957 authored by cketti's avatar cketti
Browse files

Add `RecipientNamesView` to display the recipient names

parent a1a1a1d4
Loading
Loading
Loading
Loading
+119 −0
Original line number Diff line number Diff line
package com.fsck.k9.ui.messageview

import android.text.SpannableStringBuilder

private const val LIST_SEPARATOR = ", "

/**
 * Calculates how many recipient names can be displayed given the available width.
 *
 * We display up to [maxNumberOfRecipientNames] recipient names, then the number of additional recipients.
 *
 * Example:
 *   to me, Alice, Bob, Charly, Dora +11
 *
 * If there's not enough room to display the first recipient name, we return it anyway and expect the component that is
 * actually rendering the text to ellipsize [RecipientLayoutData.recipientNames], but not
 * [RecipientLayoutData.additionalRecipients].
 */
internal class RecipientLayoutCreator(
    private val textMeasure: TextMeasure,
    private val maxNumberOfRecipientNames: Int,
    private val recipientsPrefix: String,
    private val additionalRecipientSpacing: Int,
    private val additionalRecipientsPrefix: String
) {
    fun createRecipientLayout(
        recipientNames: List<CharSequence>,
        totalNumberOfRecipients: Int,
        availableWidth: Int
    ): RecipientLayoutData {
        require(recipientNames.isNotEmpty())

        val displayRecipientsBuilder = SpannableStringBuilder()

        if (recipientNames.size == 1) {
            displayRecipientsBuilder.append(recipientsPrefix)
            displayRecipientsBuilder.append(recipientNames.first())

            return RecipientLayoutData(
                recipientNames = displayRecipientsBuilder,
                additionalRecipients = null
            )
        }

        val additionalRecipientsBuilder = StringBuilder(additionalRecipientsPrefix + 10)

        val maxRecipientNames = recipientNames.size.coerceAtMost(maxNumberOfRecipientNames)
        for (numberOfDisplayRecipients in maxRecipientNames downTo 2) {
            displayRecipientsBuilder.clear()
            displayRecipientsBuilder.append(recipientsPrefix)

            recipientNames.asSequence()
                .take(numberOfDisplayRecipients)
                .joinTo(displayRecipientsBuilder, separator = LIST_SEPARATOR)

            additionalRecipientsBuilder.setLength(0)
            val numberOfAdditionalRecipients = totalNumberOfRecipients - numberOfDisplayRecipients
            if (numberOfAdditionalRecipients > 0) {
                additionalRecipientsBuilder.append(additionalRecipientsPrefix)
                additionalRecipientsBuilder.append(numberOfAdditionalRecipients)
            }

            if (doesTextFitAvailableWidth(displayRecipientsBuilder, additionalRecipientsBuilder, availableWidth)) {
                return RecipientLayoutData(
                    recipientNames = displayRecipientsBuilder,
                    additionalRecipients = additionalRecipientsBuilder.toStringOrNull()
                )
            }
        }

        displayRecipientsBuilder.clear()
        displayRecipientsBuilder.append(recipientsPrefix)
        displayRecipientsBuilder.append(recipientNames.first())

        return RecipientLayoutData(
            recipientNames = displayRecipientsBuilder,
            additionalRecipients = "$additionalRecipientsPrefix${totalNumberOfRecipients - 1}"
        )
    }

    private fun doesTextFitAvailableWidth(
        displayRecipients: CharSequence,
        additionalRecipients: CharSequence,
        availableWidth: Int
    ): Boolean {
        val recipientNamesWidth = textMeasure.measureRecipientNames(displayRecipients)
        if (recipientNamesWidth > availableWidth) {
            return false
        } else if (additionalRecipients.isEmpty()) {
            return true
        }

        val totalWidth = recipientNamesWidth + additionalRecipientSpacing +
            textMeasure.measureRecipientCount(additionalRecipients)

        return totalWidth <= availableWidth
    }
}

private fun StringBuilder.toStringOrNull(): String? {
    return if (isEmpty()) null else toString()
}

internal data class RecipientLayoutData(
    val recipientNames: CharSequence,
    val additionalRecipients: String?
)

internal interface TextMeasure {
    /**
     * Measure the width of the supplied recipient names when rendered.
     */
    fun measureRecipientNames(text: CharSequence): Int

    /**
     * Measure the width of the supplied recipient count when rendered.
     */
    fun measureRecipientCount(text: CharSequence): Int
}
+184 −0
Original line number Diff line number Diff line
package com.fsck.k9.ui.messageview

import android.content.Context
import android.util.AttributeSet
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.TextView
import androidx.core.view.isGone
import com.fsck.k9.ui.R

private const val MAX_NUMBER_OF_RECIPIENT_NAMES = 5

/**
 * View that displays the names of recipients of a message.
 *
 * Up to [MAX_NUMBER_OF_RECIPIENT_NAMES] names of recipients are displayed, followed by the number of recipients that
 * weren't displayed.
 *
 * Examples:
 * - to me, Alice, Bob, Charly +3
 * - to Camila Hyphenated-Nam… +5
 *
 * This custom layout uses [RecipientLayoutCreator] to figure out how many recipient names can be displayed without
 * being truncated. If not even one recipient name can be displayed without being truncated, we first measure the space
 * needed for number of additional recipients, then use the rest to display the first recipient and ellipsize the end.
 */
class RecipientNamesView(context: Context, attrs: AttributeSet?) : ViewGroup(context, attrs) {
    val maxNumberOfRecipientNames: Int = MAX_NUMBER_OF_RECIPIENT_NAMES

    private val recipientLayoutCreator: RecipientLayoutCreator

    private val recipientNameTextView: TextView
    private val recipientCountTextView: TextView
    private val additionRecipientSpacing: Int

    init {
        LayoutInflater.from(context).inflate(R.layout.recipient_names, this, true)
        recipientNameTextView = findViewById(R.id.recipient_names)
        recipientCountTextView = findViewById(R.id.recipient_count)
        additionRecipientSpacing = (recipientCountTextView.layoutParams as MarginLayoutParams).marginStart
    }

    private var recipientNames: List<CharSequence> = emptyList()
    private var numberOfRecipients: Int = 0

    private val textMeasure = object : TextMeasure {
        override fun measureRecipientNames(text: CharSequence): Int {
            return measureWidth(recipientNameTextView, text)
        }

        override fun measureRecipientCount(text: CharSequence): Int {
            return measureWidth(recipientCountTextView, text)
        }

        private fun measureWidth(textView: TextView, text: CharSequence): Int {
            textView.text = text

            val widthMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
            val heightMeasureSpec = MeasureSpec.makeMeasureSpec(height, MeasureSpec.AT_MOST)
            textView.measure(widthMeasureSpec, heightMeasureSpec)

            return textView.measuredWidth
        }
    }

    init {
        recipientLayoutCreator = RecipientLayoutCreator(
            textMeasure = textMeasure,
            maxNumberOfRecipientNames = MAX_NUMBER_OF_RECIPIENT_NAMES,
            recipientsPrefix = context.getString(R.string.message_view_recipient_prefix),
            additionalRecipientSpacing = additionRecipientSpacing,
            additionalRecipientsPrefix = context.getString(R.string.message_view_additional_recipient_prefix)
        )

        if (isInEditMode) {
            recipientNames = listOf(
                "Grace Hopper", "Katherine Johnson", "Margaret Hamilton", "Adele Goldberg", "Steve Shirley"
            )
            numberOfRecipients = 8
        }
    }

    fun setTextSize(textSize: Int) {
        recipientNameTextView.setTextSize(TypedValue.COMPLEX_UNIT_SP, textSize.toFloat())
        recipientCountTextView.setTextSize(TypedValue.COMPLEX_UNIT_SP, textSize.toFloat())
    }

    fun setRecipients(recipientNames: List<CharSequence>, numberOfRecipients: Int) {
        if (recipientNames != this.recipientNames && numberOfRecipients != this.numberOfRecipients) {
            this.recipientNames = recipientNames
            this.numberOfRecipients = numberOfRecipients
            requestLayout()
        }
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        require(MeasureSpec.getMode(widthMeasureSpec) != MeasureSpec.UNSPECIFIED) {
            "Width of RecipientNamesView needs to be constrained"
        }

        recipientNameTextView.measure(widthMeasureSpec, heightMeasureSpec)
        recipientCountTextView.measure(widthMeasureSpec, heightMeasureSpec)

        val width = MeasureSpec.getSize(widthMeasureSpec)
        val height = maxOf(recipientNameTextView.measuredHeight, recipientCountTextView.measuredHeight)
        setMeasuredDimension(width, height)
    }

    override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
        val availableWidth = width

        val recipientLayoutData = recipientLayoutCreator.createRecipientLayout(
            recipientNames, numberOfRecipients, availableWidth
        )

        recipientNameTextView.text = recipientLayoutData.recipientNames
        val additionalRecipientsVisible = recipientLayoutData.additionalRecipients != null
        val remainingWidth: Int
        if (additionalRecipientsVisible) {
            recipientCountTextView.isGone = false
            recipientCountTextView.text = recipientLayoutData.additionalRecipients

            recipientCountTextView.measure(
                MeasureSpec.makeMeasureSpec(availableWidth, MeasureSpec.AT_MOST),
                MeasureSpec.makeMeasureSpec(measuredHeight, MeasureSpec.AT_MOST)
            )

            remainingWidth = availableWidth - additionRecipientSpacing - recipientCountTextView.measuredWidth
        } else {
            recipientCountTextView.isGone = true
            remainingWidth = availableWidth
        }

        recipientNameTextView.measure(
            MeasureSpec.makeMeasureSpec(remainingWidth, MeasureSpec.AT_MOST),
            MeasureSpec.makeMeasureSpec(measuredHeight, MeasureSpec.AT_MOST)
        )

        if (layoutDirection == LAYOUT_DIRECTION_LTR) {
            val recipientNameRight = recipientNameTextView.measuredWidth
            recipientNameTextView.layout(
                0,
                0,
                recipientNameRight,
                recipientNameTextView.measuredHeight
            )
            val recipientCountLeft = recipientNameRight + additionRecipientSpacing
            recipientCountTextView.layout(
                recipientCountLeft,
                0,
                recipientCountLeft + recipientCountTextView.measuredWidth,
                recipientCountTextView.measuredHeight
            )
        } else {
            val recipientNameLeft = width - recipientNameTextView.measuredWidth
            recipientNameTextView.layout(
                recipientNameLeft,
                0,
                right,
                recipientNameTextView.measuredHeight
            )
            val recipientCountRight = recipientNameLeft - additionRecipientSpacing
            recipientCountTextView.layout(
                recipientCountRight - recipientCountTextView.measuredWidth,
                0,
                recipientCountRight,
                0 + recipientCountTextView.measuredHeight
            )
        }
    }

    override fun checkLayoutParams(p: LayoutParams?): Boolean {
        return p is MarginLayoutParams
    }

    override fun generateDefaultLayoutParams(): LayoutParams {
        return MarginLayoutParams(0, 0)
    }

    override fun generateLayoutParams(attrs: AttributeSet?): LayoutParams {
        return MarginLayoutParams(context, attrs)
    }
}
+33 −6
Original line number Diff line number Diff line
@@ -25,7 +25,10 @@ import com.fsck.k9.K9;
import com.fsck.k9.activity.misc.ContactPicture;
import com.fsck.k9.contacts.ContactPictureLoader;
import com.fsck.k9.helper.ClipboardManager;
import com.fsck.k9.helper.Contacts;
import com.fsck.k9.helper.MessageHelper;
import com.fsck.k9.helper.RealAddressFormatter;
import com.fsck.k9.helper.RealContactNameProvider;
import com.fsck.k9.mail.Address;
import com.fsck.k9.mail.Flag;
import com.fsck.k9.mail.Message;
@@ -34,7 +37,10 @@ import com.fsck.k9.message.ReplyActionStrategy;
import com.fsck.k9.message.ReplyActions;
import com.fsck.k9.ui.R;
import com.fsck.k9.ui.helper.RelativeDateTimeFormatter;
import com.fsck.k9.ui.messageview.DisplayRecipients;
import com.fsck.k9.ui.messageview.DisplayRecipientsExtractor;
import com.fsck.k9.ui.messageview.MessageHeaderOnMenuItemClickListener;
import com.fsck.k9.ui.messageview.RecipientNamesView;
import com.google.android.material.chip.Chip;
import com.google.android.material.snackbar.Snackbar;

@@ -51,8 +57,7 @@ public class MessageHeader extends LinearLayout implements OnClickListener, OnLo
    private ImageView contactPictureView;
    private TextView fromView;
    private ImageView cryptoStatusIcon;
    private TextView toView;
    private TextView toCountView;
    private RecipientNamesView recipientNamesView;
    private TextView dateView;
    private ImageView menuPrimaryActionView;

@@ -82,15 +87,17 @@ public class MessageHeader extends LinearLayout implements OnClickListener, OnLo
        contactPictureView = findViewById(R.id.contact_picture);
        fromView = findViewById(R.id.from);
        cryptoStatusIcon = findViewById(R.id.crypto_status_icon);
        toView = findViewById(R.id.to);
        toCountView = findViewById(R.id.to_count);
        recipientNamesView = findViewById(R.id.recipients);
        dateView = findViewById(R.id.date);

        fontSizes.setViewTextSize(subjectView, fontSizes.getMessageViewSubject());
        fontSizes.setViewTextSize(dateView, fontSizes.getMessageViewDate());
        fontSizes.setViewTextSize(fromView, fontSizes.getMessageViewSender());
        fontSizes.setViewTextSize(toView, fontSizes.getMessageViewRecipients());
        fontSizes.setViewTextSize(toCountView, fontSizes.getMessageViewRecipients());

        int recipientTextSize = fontSizes.getMessageViewRecipients();
        if (recipientTextSize != FontSizes.FONT_DEFAULT) {
            recipientNamesView.setTextSize(recipientTextSize);
        }

        subjectView.setOnClickListener(this);
        subjectView.setOnLongClickListener(this);
@@ -230,11 +237,31 @@ public class MessageHeader extends LinearLayout implements OnClickListener, OnLo
            dateView.setText("");
        }

        setRecipientNames(message, account);

        setReplyActions(message, account);

        setVisibility(View.VISIBLE);
    }

    private void setRecipientNames(Message message, Account account) {
        Integer contactNameColor = K9.isChangeContactNameColor() ? K9.getContactNameColor() : null;

        RealContactNameProvider contactNameProvider = new RealContactNameProvider(Contacts.getInstance(getContext()));

        RealAddressFormatter addressFormatter = new RealAddressFormatter(contactNameProvider, account,
                K9.isShowCorrespondentNames(), K9.isShowContactName(), contactNameColor,
                getContext().getString(R.string.message_view_me_text));

        DisplayRecipientsExtractor displayRecipientsExtractor = new DisplayRecipientsExtractor(addressFormatter,
                recipientNamesView.getMaxNumberOfRecipientNames());

        DisplayRecipients displayRecipients = displayRecipientsExtractor.extractDisplayRecipients(message, account);

        recipientNamesView.setRecipients(displayRecipients.getRecipientNames(),
                displayRecipients.getNumberOfRecipients());
    }

    private void setReplyActions(Message message, Account account) {
        ReplyActions replyActions = replyActionStrategy.getReplyActions(account, message);
        this.replyActions = replyActions;
+7 −30
Original line number Diff line number Diff line
@@ -118,7 +118,7 @@
            android:id="@+id/date"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginEnd="16dp"
            android:layout_marginEnd="4dp"
            android:ellipsize="none"
            android:singleLine="true"
            android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
@@ -134,49 +134,26 @@
            android:layout_height="18sp"
            android:layout_marginStart="16dp"
            android:visibility="gone"
            app:layout_constraintBottom_toBottomOf="@+id/to"
            app:layout_constraintBottom_toBottomOf="@+id/recipients"
            app:layout_constraintStart_toEndOf="@id/contact_picture"
            app:srcCompat="@drawable/status_lock_disabled"
            app:tint="?attr/openpgp_grey"
            tools:visibility="visible" />

        <TextView
            android:id="@+id/to"
            android:layout_width="wrap_content"
        <com.fsck.k9.ui.messageview.RecipientNamesView
            android:id="@+id/recipients"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginStart="4dp"
            android:layout_marginEnd="4dp"
            android:layout_marginBottom="16dp"
            android:ellipsize="end"
            android:maxLines="1"
            android:text="to me"
            android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
            android:textColor="?android:attr/textColorSecondary"
            app:layout_constrainedWidth="true"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toStartOf="@id/to_count"
            app:layout_constraintHorizontal_bias="0.0"
            app:layout_constraintHorizontal_chainStyle="packed"
            app:layout_constraintEnd_toStartOf="@+id/menu_primary_action"
            app:layout_constraintStart_toEndOf="@id/crypto_status_icon"
            app:layout_constraintTop_toBottomOf="@+id/from"
            app:layout_constraintVertical_bias="0.0"
            app:layout_goneMarginStart="16dp" />

        <TextView
            android:id="@+id/to_count"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginHorizontal="4dp"
            android:layout_marginStart="4dp"
            android:layout_marginEnd="16dp"
            android:layout_weight="1"
            android:singleLine="true"
            android:text="+2"
            android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
            android:textColor="?attr/colorAccent"
            app:layout_constraintBottom_toBottomOf="@+id/to"
            app:layout_constraintEnd_toStartOf="@+id/menu_primary_action"
            app:layout_constraintStart_toEndOf="@id/to" />

        <ImageView
            android:id="@+id/menu_primary_action"
            android:layout_width="48dp"
+27 −0
Original line number Diff line number Diff line
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    tools:parentTag="LinearLayout"
    tools:orientation="horizontal">

    <TextView
        android:id="@+id/recipient_names"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:ellipsize="end"
        android:maxLines="1"
        android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
        android:textColor="?android:attr/textColorSecondary"
        tools:text="to me" />

    <TextView
        android:id="@+id/recipient_count"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="4dp"
        android:singleLine="true"
        android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
        android:textColor="?attr/colorAccent"
        tools:text="+2" />

</merge>
Loading