Skip to content

Commit

Permalink
improve preview cards (#4782)
Browse files Browse the repository at this point in the history
- new design thats more Material3-ish
- support for the Mastodon 4.3 fediverse:creator feature and other new
card attributes

closes #4732 
closes #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"/>
  • Loading branch information
connyduck authored Jan 6, 2025
1 parent d6b276d commit 510e093
Show file tree
Hide file tree
Showing 16 changed files with 225 additions and 225 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);
}
}
}

}
}

Expand Down Expand Up @@ -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() &&
Expand All @@ -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);
}
Expand All @@ -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)
Expand All @@ -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());

Expand All @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}

Expand Down
10 changes: 5 additions & 5 deletions app/src/main/java/com/keylesspalace/tusky/db/Converters.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -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>
)
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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?
)
2 changes: 1 addition & 1 deletion app/src/main/java/com/keylesspalace/tusky/entity/Status.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading

0 comments on commit 510e093

Please sign in to comment.