From 510e093456fadc247a74612dec6a8541a7ae9b3c Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Mon, 6 Jan 2025 10:27:39 +0100 Subject: [PATCH] improve preview cards (#4782) - new design thats more Material3-ish - support for the Mastodon 4.3 fediverse:creator feature and other new card attributes closes #4732 closes https://github.com/tuskyapp/Tusky/issues/3340 before: after: --- .../tusky/adapter/StatusBaseViewHolder.java | 106 ++++++++++++------ .../compose/view/PollPreviewView.kt | 20 ++-- .../com/keylesspalace/tusky/db/Converters.kt | 10 +- .../tusky/db/dao/TimelineStatusDao.kt | 4 +- .../tusky/db/entity/TimelineStatusEntity.kt | 4 +- .../tusky/entity/{Card.kt => PreviewCard.kt} | 17 ++- .../com/keylesspalace/tusky/entity/Status.kt | 2 +- .../keylesspalace/tusky/view/LicenseCard.kt | 6 +- app/src/main/res/drawable/card_frame.xml | 5 - app/src/main/res/layout/item_preview_card.xml | 76 +++++++++++++ app/src/main/res/layout/item_status.xml | 63 +---------- .../main/res/layout/item_status_detailed.xml | 69 +----------- app/src/main/res/layout/view_poll_preview.xml | 56 ++++----- app/src/main/res/values/dimens.xml | 7 +- app/src/main/res/values/strings.xml | 3 + app/src/main/res/values/styles.xml | 2 - 16 files changed, 225 insertions(+), 225 deletions(-) rename app/src/main/java/com/keylesspalace/tusky/entity/{Card.kt => PreviewCard.kt} (74%) delete mode 100644 app/src/main/res/drawable/card_frame.xml create mode 100644 app/src/main/res/layout/item_preview_card.xml diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index 41ec96683a..5ec425b5bd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -6,6 +6,7 @@ import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; +import android.net.Uri; import android.text.Spanned; import android.text.TextUtils; import android.text.format.DateUtils; @@ -34,6 +35,7 @@ import com.bumptech.glide.Glide; import com.bumptech.glide.RequestBuilder; import com.google.android.material.button.MaterialButton; +import com.google.android.material.card.MaterialCardView; import com.google.android.material.color.MaterialColors; import com.google.android.material.imageview.ShapeableImageView; import com.google.android.material.shape.CornerFamily; @@ -43,10 +45,11 @@ import com.keylesspalace.tusky.entity.Attachment; import com.keylesspalace.tusky.entity.Attachment.Focus; import com.keylesspalace.tusky.entity.Attachment.MetaData; -import com.keylesspalace.tusky.entity.Card; +import com.keylesspalace.tusky.entity.PreviewCard; import com.keylesspalace.tusky.entity.Emoji; import com.keylesspalace.tusky.entity.HashTag; import com.keylesspalace.tusky.entity.Status; +import com.keylesspalace.tusky.entity.TimelineAccount; import com.keylesspalace.tusky.entity.Translation; import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.util.AbsoluteTimeFormatter; @@ -111,12 +114,14 @@ public static class Key { private final TextView pollDescription; private final Button pollButton; - private final LinearLayout cardView; - private final LinearLayout cardInfo; + private final MaterialCardView cardView; + private final LinearLayout cardLayout; private final ShapeableImageView cardImage; private final TextView cardTitle; - private final TextView cardDescription; - private final TextView cardUrl; + private final TextView cardMetadata; + private final TextView cardAuthor; + private final TextView cardAuthorButton; + private final PollAdapter pollAdapter; protected final ConstraintLayout statusContainer; private final TextView translationStatusView; @@ -169,11 +174,12 @@ protected StatusBaseViewHolder(@NonNull View itemView) { pollButton = itemView.findViewById(R.id.status_poll_button); cardView = itemView.findViewById(R.id.status_card_view); - cardInfo = itemView.findViewById(R.id.card_info); + cardLayout = itemView.findViewById(R.id.status_card_layout); cardImage = itemView.findViewById(R.id.card_image); cardTitle = itemView.findViewById(R.id.card_title); - cardDescription = itemView.findViewById(R.id.card_description); - cardUrl = itemView.findViewById(R.id.card_link); + cardMetadata = itemView.findViewById(R.id.card_metadata); + cardAuthor = itemView.findViewById(R.id.card_author); + cardAuthorButton = itemView.findViewById(R.id.card_author_button); statusContainer = itemView.findViewById(R.id.status_container); @@ -830,9 +836,12 @@ public void setupWithStatus(@NonNull StatusViewData.Concrete status, for (Object item : (List) payloads) { if (Key.KEY_CREATED.equals(item)) { setMetaData(status, statusDisplayOptions, listener); + if (status.getStatus().getCard() != null && status.getStatus().getCard().getPublishedAt() != null) { + // there is a preview card showing the published time, we need to refresh it as well + setupCard(status, status.isExpanded(), statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener); + } } } - } } @@ -1128,8 +1137,10 @@ protected void setupCard( return; } + final Context context = cardView.getContext(); + final Status actionable = status.getActionable(); - final Card card = actionable.getCard(); + final PreviewCard card = actionable.getCard(); if (cardViewMode != CardViewMode.NONE && actionable.getAttachments().isEmpty() && @@ -1141,45 +1152,74 @@ protected void setupCard( cardView.setVisibility(View.VISIBLE); cardTitle.setText(card.getTitle()); - if (TextUtils.isEmpty(card.getDescription()) && TextUtils.isEmpty(card.getAuthorName())) { - cardDescription.setVisibility(View.GONE); + + String providerName = card.getProviderName(); + if (TextUtils.isEmpty(providerName)) { + providerName = Uri.parse(card.getUrl()).getHost(); + } + + if (TextUtils.isEmpty(providerName) && card.getPublishedAt() != null) { + cardMetadata.setVisibility(View.GONE); } else { - cardDescription.setVisibility(View.VISIBLE); - if (TextUtils.isEmpty(card.getDescription())) { - cardDescription.setText(card.getAuthorName()); + cardMetadata.setVisibility(View.VISIBLE); + if (card.getPublishedAt() == null) { + cardMetadata.setText(providerName); } else { - cardDescription.setText(card.getDescription()); + String metadataJoiner = context.getString(R.string.metadata_joiner); + cardMetadata.setText(providerName + metadataJoiner + TimestampUtils.getRelativeTimeSpanString(context, card.getPublishedAt().getTime(), System.currentTimeMillis())); } } - cardUrl.setText(card.getUrl()); + String cardAuthorName; + final TimelineAccount cardAuthorAccount; + if (card.getAuthors().isEmpty()) { + cardAuthorAccount = null; + cardAuthorName = card.getAuthorName(); + } else { + cardAuthorName = card.getAuthors().get(0).getName(); + cardAuthorAccount = card.getAuthors().get(0).getAccount(); + if (cardAuthorAccount != null) { + cardAuthorName = cardAuthorAccount.getName(); + } + } + + if (TextUtils.isEmpty(cardAuthorName)) { + cardAuthor.setVisibility(View.VISIBLE); + cardAuthor.setText(card.getDescription()); + cardAuthorButton.setVisibility(View.GONE); + } else if (cardAuthorAccount == null) { + cardAuthor.setVisibility(View.VISIBLE); + cardAuthor.setText(context.getString(R.string.preview_card_by_author, cardAuthorName)); + cardAuthorButton.setVisibility(View.GONE); + } else { + cardAuthorButton.setVisibility(View.VISIBLE); + final String buttonText = context.getString(R.string.preview_card_more_by_author, cardAuthorName); + final CharSequence emojifiedButtonText = CustomEmojiHelper.emojify(buttonText, cardAuthorAccount.getEmojis(), cardAuthorButton, statusDisplayOptions.animateEmojis()); + cardAuthorButton.setText(emojifiedButtonText); + cardAuthorButton.setOnClickListener(v-> listener.onViewAccount(cardAuthorAccount.getId())); + cardAuthor.setVisibility(View.GONE); + } // Statuses from other activitypub sources can be marked sensitive even if there's no media, // so let's blur the preview in that case // If media previews are disabled, show placeholder for cards as well if (statusDisplayOptions.mediaPreviewEnabled() && !actionable.getSensitive() && !TextUtils.isEmpty(card.getImage())) { - int radius = cardImage.getContext().getResources() - .getDimensionPixelSize(R.dimen.card_radius); + int radius = context.getResources().getDimensionPixelSize(R.dimen.inner_card_radius); ShapeAppearanceModel.Builder cardImageShape = ShapeAppearanceModel.builder(); if (card.getWidth() > card.getHeight()) { - cardView.setOrientation(LinearLayout.VERTICAL); - + cardLayout.setOrientation(LinearLayout.VERTICAL); cardImage.getLayoutParams().height = cardImage.getContext().getResources() .getDimensionPixelSize(R.dimen.card_image_vertical_height); cardImage.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT; - cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT; - cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.WRAP_CONTENT; cardImageShape.setTopLeftCorner(CornerFamily.ROUNDED, radius); cardImageShape.setTopRightCorner(CornerFamily.ROUNDED, radius); } else { - cardView.setOrientation(LinearLayout.HORIZONTAL); + cardLayout.setOrientation(LinearLayout.HORIZONTAL); cardImage.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT; cardImage.getLayoutParams().width = cardImage.getContext().getResources() .getDimensionPixelSize(R.dimen.card_image_horizontal_width); - cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.WRAP_CONTENT; - cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT; cardImageShape.setTopLeftCorner(CornerFamily.ROUNDED, radius); cardImageShape.setBottomLeftCorner(CornerFamily.ROUNDED, radius); } @@ -1197,14 +1237,12 @@ protected void setupCard( .into(cardImage); } else if (statusDisplayOptions.useBlurhash() && !TextUtils.isEmpty(card.getBlurhash())) { int radius = cardImage.getContext().getResources() - .getDimensionPixelSize(R.dimen.card_radius); + .getDimensionPixelSize(R.dimen.inner_card_radius); - cardView.setOrientation(LinearLayout.HORIZONTAL); + cardLayout.setOrientation(LinearLayout.HORIZONTAL); cardImage.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT; cardImage.getLayoutParams().width = cardImage.getContext().getResources() .getDimensionPixelSize(R.dimen.card_image_horizontal_width); - cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.WRAP_CONTENT; - cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT; ShapeAppearanceModel cardImageShape = ShapeAppearanceModel.builder() .setTopLeftCorner(CornerFamily.ROUNDED, radius) @@ -1218,12 +1256,10 @@ protected void setupCard( .load(decodeBlurHash(card.getBlurhash())) .into(cardImage); } else { - cardView.setOrientation(LinearLayout.HORIZONTAL); + cardLayout.setOrientation(LinearLayout.HORIZONTAL); cardImage.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT; cardImage.getLayoutParams().width = cardImage.getContext().getResources() .getDimensionPixelSize(R.dimen.card_image_horizontal_width); - cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.WRAP_CONTENT; - cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT; cardImage.setShapeAppearanceModel(new ShapeAppearanceModel()); @@ -1238,11 +1274,9 @@ protected void setupCard( cardView.setOnClickListener(visitLink); // View embedded photos in our image viewer instead of opening the browser - cardImage.setOnClickListener(card.getType().equals(Card.TYPE_PHOTO) && !TextUtils.isEmpty(card.getEmbedUrl()) ? + cardImage.setOnClickListener(card.getType().equals(PreviewCard.TYPE_PHOTO) && !TextUtils.isEmpty(card.getEmbedUrl()) ? v -> cardView.getContext().startActivity(ViewMediaActivity.newSingleImageIntent(cardView.getContext(), card.getEmbedUrl())) : visitLink); - - cardView.setClipToOutline(true); } else { cardView.setVisibility(View.GONE); } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/PollPreviewView.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/PollPreviewView.kt index c55e8fce72..a2fb694800 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/PollPreviewView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/PollPreviewView.kt @@ -16,9 +16,12 @@ package com.keylesspalace.tusky.components.compose.view import android.content.Context +import android.content.res.ColorStateList import android.util.AttributeSet import android.view.LayoutInflater -import android.widget.LinearLayout +import com.google.android.material.R as materialR +import com.google.android.material.card.MaterialCardView +import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.PreviewPollOptionsAdapter import com.keylesspalace.tusky.databinding.ViewPollPreviewBinding @@ -27,23 +30,18 @@ import com.keylesspalace.tusky.entity.NewPoll class PollPreviewView @JvmOverloads constructor( context: Context?, attrs: AttributeSet? = null, - defStyleAttr: Int = 0 + defStyleAttr: Int = materialR.attr.materialCardViewOutlinedStyle ) : - LinearLayout(context, attrs, defStyleAttr) { + MaterialCardView(context, attrs, defStyleAttr) { private val adapter = PreviewPollOptionsAdapter() private val binding = ViewPollPreviewBinding.inflate(LayoutInflater.from(context), this) init { - orientation = VERTICAL - - setBackgroundResource(R.drawable.card_frame) - - val padding = resources.getDimensionPixelSize(R.dimen.poll_preview_padding) - - setPadding(padding, padding, padding, padding) - + setStrokeColor(ColorStateList.valueOf(MaterialColors.getColor(this, materialR.attr.colorOutline))) + strokeWidth + elevation = 0f binding.pollPreviewOptions.adapter = adapter } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt index 5f36c0a776..41598d4a4c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt @@ -22,12 +22,12 @@ import com.keylesspalace.tusky.components.conversation.ConversationAccountEntity import com.keylesspalace.tusky.createTabDataFromId import com.keylesspalace.tusky.db.entity.DraftAttachment import com.keylesspalace.tusky.entity.Attachment -import com.keylesspalace.tusky.entity.Card import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.FilterResult import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.entity.PreviewCard import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.settings.DefaultReplyVisibility import com.squareup.moshi.Moshi @@ -196,13 +196,13 @@ class Converters @Inject constructor( } @TypeConverter - fun cardToJson(card: Card?): String { - return moshi.adapter().toJson(card) + fun cardToJson(card: PreviewCard?): String { + return moshi.adapter().toJson(card) } @TypeConverter - fun jsonToCard(cardJson: String?): Card? { - return cardJson?.let { moshi.adapter().fromJson(cardJson) } + fun jsonToCard(cardJson: String?): PreviewCard? { + return cardJson?.let { moshi.adapter().fromJson(cardJson) } } @TypeConverter diff --git a/app/src/main/java/com/keylesspalace/tusky/db/dao/TimelineStatusDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/dao/TimelineStatusDao.kt index 17dbe75dd2..66c7597111 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/dao/TimelineStatusDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/dao/TimelineStatusDao.kt @@ -26,10 +26,10 @@ import com.keylesspalace.tusky.db.Converters import com.keylesspalace.tusky.db.entity.TimelineAccountEntity import com.keylesspalace.tusky.db.entity.TimelineStatusEntity import com.keylesspalace.tusky.entity.Attachment -import com.keylesspalace.tusky.entity.Card import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.entity.PreviewCard import com.keylesspalace.tusky.entity.Status import com.squareup.moshi.Moshi import com.squareup.moshi.adapter @@ -81,7 +81,7 @@ AND s.tuskyAccountId = :tuskyAccountId""" poll = moshi.adapter().toJson(status.poll), muted = status.muted, pinned = status.pinned, - card = moshi.adapter().toJson(status.card), + card = moshi.adapter().toJson(status.card), language = status.language ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/entity/TimelineStatusEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/entity/TimelineStatusEntity.kt index 2b377d687e..ec36888641 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/entity/TimelineStatusEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/entity/TimelineStatusEntity.kt @@ -21,11 +21,11 @@ import androidx.room.Index import androidx.room.TypeConverters import com.keylesspalace.tusky.db.Converters import com.keylesspalace.tusky.entity.Attachment -import com.keylesspalace.tusky.entity.Card import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.FilterResult import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.entity.PreviewCard import com.keylesspalace.tusky.entity.Status /** @@ -81,7 +81,7 @@ data class TimelineStatusEntity( val contentCollapsed: Boolean, val contentShowing: Boolean, val pinned: Boolean, - val card: Card?, + val card: PreviewCard?, val language: String?, val filtered: List ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Card.kt b/app/src/main/java/com/keylesspalace/tusky/entity/PreviewCard.kt similarity index 74% rename from app/src/main/java/com/keylesspalace/tusky/entity/Card.kt rename to app/src/main/java/com/keylesspalace/tusky/entity/PreviewCard.kt index baba8cefaf..c90b025f20 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Card.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/PreviewCard.kt @@ -17,13 +17,17 @@ package com.keylesspalace.tusky.entity import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import java.util.Date @JsonClass(generateAdapter = true) -data class Card( +data class PreviewCard( val url: String, val title: String, val description: String = "", - @Json(name = "author_name") val authorName: String = "", + val authors: List = emptyList(), + @Json(name = "author_name") val authorName: String? = null, + @Json(name = "provider_name") val providerName: String? = null, + @Json(name = "published_at") val publishedAt: Date?, val image: String? = null, val type: String, val width: Int = 0, @@ -35,7 +39,7 @@ data class Card( override fun hashCode() = url.hashCode() override fun equals(other: Any?): Boolean { - if (other !is Card) { + if (other !is PreviewCard) { return false } return other.url == this.url @@ -45,3 +49,10 @@ data class Card( const val TYPE_PHOTO = "photo" } } + +@JsonClass(generateAdapter = true) +data class PreviewCardAuthor( + val name: String, + val url: String, + val account: TimelineAccount? +) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt index 4812eefe73..67b34e5492 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt @@ -53,7 +53,7 @@ data class Status( val muted: Boolean = false, val poll: Poll? = null, /** Preview card for links included within status content. */ - val card: Card? = null, + val card: PreviewCard? = null, /** ISO 639 language code for this status. */ val language: String? = null, /** If the current token has an authorized user: The filter and keywords that matched this status. diff --git a/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt b/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt index e6eb5de04a..8a3f6e4e99 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt @@ -16,13 +16,11 @@ package com.keylesspalace.tusky.view import android.content.Context -import android.content.res.ColorStateList import android.util.AttributeSet import android.view.LayoutInflater import androidx.core.content.res.use import com.google.android.material.R as materialR import com.google.android.material.card.MaterialCardView -import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.CardLicenseBinding import com.keylesspalace.tusky.util.hide @@ -32,14 +30,12 @@ class LicenseCard @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, - defStyleAttr: Int = R.attr.licenseCardStyle + defStyleAttr: Int = materialR.attr.materialCardViewFilledStyle ) : MaterialCardView(context, attrs, defStyleAttr) { init { val binding = CardLicenseBinding.inflate(LayoutInflater.from(context), this) - setStrokeColor(ColorStateList.valueOf(MaterialColors.getColor(this, materialR.attr.colorOutline))) - val (name, license, link) = context.theme.obtainStyledAttributes( attrs, R.styleable.LicenseCard, diff --git a/app/src/main/res/drawable/card_frame.xml b/app/src/main/res/drawable/card_frame.xml deleted file mode 100644 index 525731b64b..0000000000 --- a/app/src/main/res/drawable/card_frame.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_preview_card.xml b/app/src/main/res/layout/item_preview_card.xml new file mode 100644 index 0000000000..6fb7d30587 --- /dev/null +++ b/app/src/main/res/layout/item_preview_card.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + +