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:

<img
src="https://github.com/user-attachments/assets/6cd9ccfc-7f7d-459b-90d9-547cdca0d8c4"
width="280"/>
<img
src="https://github.com/user-attachments/assets/286b5b19-49a3-4b2f-97a9-185fc1f31a8e"
width="280"/>


after:
<img
src="https://github.com/user-attachments/assets/b57acf74-e7d3-411e-9186-763de87fa9ca"
width="280"/> <img
src="https://github.com/user-attachments/assets/50684c30-b4bf-4f05-8b8e-e5fd2bf3d7b6"
width="280"/>
This commit is contained in:
Konrad Pozniak 2025-01-06 10:27:39 +01:00 committed by GitHub
commit 510e093456
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 225 additions and 225 deletions

View file

@ -6,6 +6,7 @@ import android.content.Context;
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 androidx.recyclerview.widget.RecyclerView;
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.ViewMediaActivity;
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 abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
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 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
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 abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
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 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
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 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
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 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
.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 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
.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 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
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);
}

View file

@ -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
}

View file

@ -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<Card?>().toJson(card)
fun cardToJson(card: PreviewCard?): String {
return moshi.adapter<PreviewCard?>().toJson(card)
}
@TypeConverter
fun jsonToCard(cardJson: String?): Card? {
return cardJson?.let { moshi.adapter<Card?>().fromJson(cardJson) }
fun jsonToCard(cardJson: String?): PreviewCard? {
return cardJson?.let { moshi.adapter<PreviewCard?>().fromJson(cardJson) }
}
@TypeConverter

View file

@ -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<Poll?>().toJson(status.poll),
muted = status.muted,
pinned = status.pinned,
card = moshi.adapter<Card?>().toJson(status.card),
card = moshi.adapter<PreviewCard?>().toJson(status.card),
language = status.language
)
}

View file

@ -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<FilterResult>
)

View file

@ -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<PreviewCardAuthor> = 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?
)

View file

@ -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.

View file

@ -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,