Link previews for detail statuses (#424)

* implement link preview cards on detail statuses

* cleanup code
This commit is contained in:
Konrad Pozniak 2017-10-27 13:20:17 +02:00 committed by GitHub
parent df4dfa7766
commit 5cbc7217ff
14 changed files with 420 additions and 35 deletions

View file

@ -1,20 +1,28 @@
package com.keylesspalace.tusky.adapter;
import android.content.Context;
import android.os.Build;
import android.preference.PreferenceManager;
import android.support.annotation.Nullable;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.method.LinkMovementMethod;
import android.text.style.URLSpan;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.entity.Card;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.CustomTabURLSpan;
import com.keylesspalace.tusky.util.LinkHelper;
import com.keylesspalace.tusky.viewdata.StatusViewData;
import com.squareup.picasso.Picasso;
import java.text.DateFormat;
import java.util.Date;
@ -23,12 +31,24 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
private TextView reblogs;
private TextView favourites;
private TextView application;
private LinearLayout cardView;
private LinearLayout cardInfo;
private ImageView cardImage;
private TextView cardTitle;
private TextView cardDescription;
private TextView cardUrl;
StatusDetailedViewHolder(View view) {
super(view);
reblogs = view.findViewById(R.id.status_reblogs);
favourites = view.findViewById(R.id.status_favourites);
application = view.findViewById(R.id.status_application);
cardView = view.findViewById(R.id.card_view);
cardInfo = view.findViewById(R.id.card_info);
cardImage = view.findViewById(R.id.card_image);
cardTitle = view.findViewById(R.id.card_title);
cardDescription = view.findViewById(R.id.card_description);
cardUrl = view.findViewById(R.id.card_link);
}
@Override
@ -65,11 +85,68 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
}
@Override
void setupWithStatus(StatusViewData status, final StatusActionListener listener,
void setupWithStatus(final StatusViewData status, final StatusActionListener listener,
boolean mediaPreviewEnabled) {
super.setupWithStatus(status, listener, mediaPreviewEnabled);
reblogs.setText(status.getReblogsCount());
favourites.setText(status.getFavouritesCount());
setApplication(status.getApplication());
if(status.getAttachments().length == 0 && status.getCard() != null && !TextUtils.isEmpty(status.getCard().url)) {
final Card card = status.getCard();
cardView.setVisibility(View.VISIBLE);
cardTitle.setText(card.title);
cardDescription.setText(card.description);
cardUrl.setText(card.url);
if(card.width > 0 && card.height > 0 && !TextUtils.isEmpty(card.image)) {
cardImage.setVisibility(View.VISIBLE);
if(card.width > card.height) {
cardView.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;
} else {
cardView.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;
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
cardView.setClipToOutline(true);
}
Picasso.with(cardImage.getContext())
.load(card.image)
.fit()
.centerCrop()
.into(cardImage);
} else {
cardImage.setVisibility(View.GONE);
}
cardView.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
LinkHelper.openLink(card.url, v.getContext());
}
});
} else {
cardView.setVisibility(View.GONE);
}
}
}

View file

@ -22,6 +22,7 @@ import android.view.View;
import android.view.ViewGroup;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.entity.Card;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.viewdata.StatusViewData;
@ -37,6 +38,8 @@ public class ThreadAdapter extends RecyclerView.Adapter {
private boolean mediaPreviewEnabled;
private int detailedStatusPosition;
private Card detailedStatusCard;
public ThreadAdapter(StatusActionListener listener) {
this.statusActionListener = listener;
this.statuses = new ArrayList<>();

View file

@ -0,0 +1,96 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.entity;
import android.os.Parcel;
import android.os.Parcelable;
import android.text.Spanned;
import com.google.gson.annotations.SerializedName;
import com.keylesspalace.tusky.util.HtmlUtils;
public class Card implements Parcelable {
public String url;
public String title;
public String description;
public String image;
public String type;
public int width;
public int height;
@Override
public int hashCode() {
return url.hashCode();
}
@Override
public boolean equals(Object other) {
if (this.url == null) {
return this == other;
} else if (!(other instanceof Card)) {
return false;
}
Card account = (Card) other;
return account.url.equals(this.url);
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeString(url);
dest.writeString(title);
dest.writeString(description);
dest.writeString(image);
dest.writeString(type);
dest.writeInt(width);
dest.writeInt(height);
}
public Card() {}
private Card(Parcel in) {
url = in.readString();
title = in.readString();
description = in.readString();
image = in.readString();
type = in.readString();
width = in.readInt();
height = in.readInt();
}
public static final Creator<Card> CREATOR = new Creator<Card>() {
@Override
public Card createFromParcel(Parcel source) {
return new Card(source);
}
@Override
public Card[] newArray(int size) {
return new Card[size];
}
};
}

View file

@ -37,6 +37,7 @@ import android.view.ViewGroup;
import com.keylesspalace.tusky.BuildConfig;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.adapter.ThreadAdapter;
import com.keylesspalace.tusky.entity.Card;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.entity.StatusContext;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
@ -64,6 +65,7 @@ public class ViewThreadFragment extends SFragment implements
private ThreadAdapter adapter;
private String thisThreadsStatusId;
private TimelineReceiver timelineReceiver;
private Card card;
private int statusIndex = 0;
@ -135,6 +137,7 @@ public class ViewThreadFragment extends SFragment implements
public void onRefresh() {
sendStatusRequest(thisThreadsStatusId);
sendThreadRequest(thisThreadsStatusId);
sendCardRequest(thisThreadsStatusId);
}
@Override
@ -327,6 +330,26 @@ public class ViewThreadFragment extends SFragment implements
callList.add(call);
}
private void sendCardRequest(final String id) {
Call<Card> call = mastodonApi.statusCard(id);
call.enqueue(new Callback<Card>() {
@Override
public void onResponse(@NonNull Call<Card> call, @NonNull Response<Card> response) {
if (response.isSuccessful()) {
showCard(response.body());
} else {
onThreadRequestFailure(id);
}
}
@Override
public void onFailure(@NonNull Call<Card> call, @NonNull Throwable t) {
onThreadRequestFailure(id);
}
});
callList.add(call);
}
private void onThreadRequestFailure(final String id) {
View view = getView();
swipeRefreshLayout.setRefreshing(false);
@ -356,7 +379,13 @@ public class ViewThreadFragment extends SFragment implements
int i = statusIndex;
statuses.add(i, status);
adapter.setDetailedStatusPosition(i);
adapter.addItem(i, statuses.getPairedItem(i));
StatusViewData viewData = statuses.getPairedItem(i);
if(viewData.getCard() == null && card != null) {
viewData = new StatusViewData.Builder(viewData)
.setCard(card)
.createStatusViewData();
}
adapter.addItem(i, viewData);
return i;
}
@ -391,7 +420,13 @@ public class ViewThreadFragment extends SFragment implements
// In case we needed to delete everything (which is way easier than deleting
// everything except one), re-insert the remaining status here.
statuses.add(statusIndex, mainStatus);
adapter.addItem(statusIndex, statuses.getPairedItem(statusIndex));
StatusViewData viewData = statuses.getPairedItem(statusIndex);
if(viewData.getCard() == null && card != null) {
viewData = new StatusViewData.Builder(viewData)
.setCard(card)
.createStatusViewData();
}
adapter.addItem(statusIndex, viewData);
}
// Insert newly fetched descendants
@ -410,6 +445,21 @@ public class ViewThreadFragment extends SFragment implements
adapter.addAll(descendantsViewData);
}
private void showCard(Card card) {
this.card = card;
if(statuses.size() != 0) {
StatusViewData oldViewData = statuses.getPairedItem(statusIndex);
if(oldViewData != null) {
StatusViewData newViewData = new StatusViewData.Builder(statuses.getPairedItem(statusIndex))
.setCard(card)
.createStatusViewData();
statuses.setPairedItem(statusIndex, newViewData);
adapter.setItem(statusIndex, newViewData, true);
}
}
}
public void clear() {
statuses.clear();
adapter.clear();

View file

@ -18,6 +18,7 @@ package com.keylesspalace.tusky.network;
import com.keylesspalace.tusky.entity.AccessToken;
import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.entity.AppCredentials;
import com.keylesspalace.tusky.entity.Card;
import com.keylesspalace.tusky.entity.Media;
import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.Profile;
@ -215,4 +216,9 @@ public interface MastodonApi {
@Field("code") String code,
@Field("grant_type") String grantType
);
@GET("/api/v1/statuses/{id}/card")
Call<Card> statusCard(
@Path("id") String statusId
);
}

View file

@ -1,19 +1,11 @@
package com.keylesspalace.tusky.util;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.net.Uri;
import android.os.Parcel;
import android.os.Parcelable;
import android.preference.PreferenceManager;
import android.support.customtabs.CustomTabsIntent;
import android.support.v4.content.ContextCompat;
import android.text.style.URLSpan;
import android.util.Log;
import android.view.View;
import com.keylesspalace.tusky.R;
public class CustomTabURLSpan extends URLSpan {
public CustomTabURLSpan(String url) {
super(url);
@ -37,27 +29,8 @@ public class CustomTabURLSpan extends URLSpan {
};
@Override
public void onClick(View widget) {
public void onClick(View view) {
Uri uri = Uri.parse(getURL());
Context context = widget.getContext();
boolean lightTheme = PreferenceManager.getDefaultSharedPreferences(context).getBoolean("lightTheme", false);
int toolbarColor = ContextCompat.getColor(context, lightTheme ? R.color.custom_tab_toolbar_light : R.color.custom_tab_toolbar_dark);
CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder();
builder.setToolbarColor(toolbarColor);
CustomTabsIntent customTabsIntent = builder.build();
try {
String packageName = CustomTabsHelper.getPackageNameToUse(context);
//If we cant find a package name, it means theres no browser that supports
//Chrome Custom Tabs installed. So, we fallback to the webview
if (packageName == null) {
super.onClick(widget);
} else {
customTabsIntent.intent.setPackage(packageName);
customTabsIntent.launchUrl(context, uri);
}
} catch (ActivityNotFoundException e) {
Log.w("URLSpan", "Activity was not found for intent, " + customTabsIntent.toString());
}
LinkHelper.openLinkInCustomTab(uri, view.getContext());
}
}

View file

@ -15,15 +15,25 @@
package com.keylesspalace.tusky.util;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.preference.PreferenceManager;
import android.provider.Browser;
import android.support.annotation.Nullable;
import android.support.customtabs.CustomTabsIntent;
import android.support.v4.content.ContextCompat;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.method.LinkMovementMethod;
import android.text.style.ClickableSpan;
import android.text.style.URLSpan;
import android.util.Log;
import android.view.View;
import android.widget.TextView;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.LinkListener;
@ -111,4 +121,71 @@ public class LinkHelper {
view.setLinksClickable(true);
view.setMovementMethod(LinkMovementMethod.getInstance());
}
/**
* Opens a link, depending on the settings, either in the browser or in a custom tab
*
* @param url a string containing the url to open
* @param context context
*/
public static void openLink(String url, Context context) {
Uri uri = Uri.parse(url);
boolean useCustomTabs = PreferenceManager.getDefaultSharedPreferences(context)
.getBoolean("customTabs", true);
if (useCustomTabs) {
openLinkInCustomTab(uri, context);
} else {
openLinkInBrowser(uri, context);
}
}
/**
* opens a link in the browser via Intent.ACTION_VIEW
*
* @param uri the uri to open
* @param context context
*/
public static void openLinkInBrowser(Uri uri, Context context) {
Intent intent = new Intent(Intent.ACTION_VIEW, uri);
intent.putExtra(Browser.EXTRA_APPLICATION_ID, context.getPackageName());
try {
context.startActivity(intent);
} catch (ActivityNotFoundException e) {
Log.w("URLSpan", "Actvity was not found for intent, " + intent.toString());
}
}
/**
* tries to open a link in a custom tab
* falls back to browser if not possible
*
* @param uri the uri to open
* @param context context
*/
public static void openLinkInCustomTab(Uri uri, Context context) {
boolean lightTheme = PreferenceManager.getDefaultSharedPreferences(context).getBoolean("lightTheme", false);
int toolbarColor = ContextCompat.getColor(context, lightTheme ? R.color.custom_tab_toolbar_light : R.color.custom_tab_toolbar_dark);
CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder();
builder.setToolbarColor(toolbarColor);
CustomTabsIntent customTabsIntent = builder.build();
try {
String packageName = CustomTabsHelper.getPackageNameToUse(context);
//If we cant find a package name, it means theres no browser that supports
//Chrome Custom Tabs installed. So, we fallback to the webview
if (packageName == null) {
openLinkInBrowser(uri, context);
} else {
customTabsIntent.intent.setPackage(packageName);
customTabsIntent.launchUrl(context, uri);
}
} catch (ActivityNotFoundException e) {
Log.w("URLSpan", "Activity was not found for intent, " + customTabsIntent.toString());
}
}
}

View file

@ -3,6 +3,7 @@ package com.keylesspalace.tusky.viewdata;
import android.support.annotation.Nullable;
import android.text.Spanned;
import com.keylesspalace.tusky.entity.Card;
import com.keylesspalace.tusky.entity.Status;
import java.util.Collections;
@ -44,6 +45,8 @@ public final class StatusViewData {
private final boolean rebloggingEnabled;
private final Status.Application application;
private final List<Status.Emoji> emojis;
@Nullable
private final Card card;
public StatusViewData(String id, Spanned content, boolean reblogged, boolean favourited,
String spoilerText, Status.Visibility visibility, Status.MediaAttachment[] attachments,
@ -51,7 +54,7 @@ public final class StatusViewData {
boolean isShowingSensitiveWarning, String userFullName, String nickname, String avatar,
Date createdAt, String reblogsCount, String favouritesCount, String inReplyToId,
Status.Mention[] mentions, String senderId, boolean rebloggingEnabled,
Status.Application application, List<Status.Emoji> emojis) {
Status.Application application, List<Status.Emoji> emojis, Card card) {
this.id = id;
this.content = content;
this.reblogged = reblogged;
@ -76,6 +79,7 @@ public final class StatusViewData {
this.rebloggingEnabled = rebloggingEnabled;
this.application = application;
this.emojis = emojis;
this.card = card;
}
public String getId() {
@ -179,6 +183,10 @@ public final class StatusViewData {
return emojis;
}
public Card getCard() {
return card;
}
public static class Builder {
private String id;
private Spanned content;
@ -204,6 +212,7 @@ public final class StatusViewData {
private boolean rebloggingEnabled;
private Status.Application application;
private List<Status.Emoji> emojis;
private Card card;
public Builder() {
}
@ -233,6 +242,8 @@ public final class StatusViewData {
rebloggingEnabled = viewData.rebloggingEnabled;
application = viewData.application;
emojis = viewData.getEmojis();
card = viewData.getCard();
}
public Builder setId(String id) {
@ -355,12 +366,17 @@ public final class StatusViewData {
return this;
}
public Builder setCard(Card card) {
this.card = card;
return this;
}
public StatusViewData createStatusViewData() {
if (this.emojis == null) emojis = Collections.emptyList();
return new StatusViewData(id, content, reblogged, favourited, spoilerText, visibility,
attachments, rebloggedByUsername, rebloggedAvatar, isSensitive, isExpanded,
isShowingSensitiveContent, userFullName, nickname, avatar, createdAt, reblogsCount,
favouritesCount, inReplyToId, mentions, senderId, rebloggingEnabled, application, emojis);
favouritesCount, inReplyToId, mentions, senderId, rebloggingEnabled, application, emojis, card);
}
}
}

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="5dp" />
<stroke android:color="@color/text_color_tertiary_dark" android:width="1px" />
<padding android:bottom="1px" android:top="1px" android:left="1px" android:right="1px"/>
</shape>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="5dp" />
<stroke android:color="@color/text_color_tertiary_light" android:width="1px" />
<padding android:bottom="1px" android:top="1px" android:left="1px" android:right="1px"/>
</shape>

View file

@ -104,11 +104,72 @@
android:textAppearance="@android:style/TextAppearance.DeviceDefault.Medium"
android:textColor="?android:textColorPrimary" />
<LinearLayout
android:id="@+id/card_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_below="@+id/status_content"
android:layout_marginTop="4dp"
android:layout_toEndOf="@+id/status_avatar"
android:layout_toRightOf="@+id/status_avatar"
android:background="?attr/card_background"
android:clipChildren="true"
android:orientation="vertical">
<ImageView
android:id="@+id/card_image"
android:layout_width="match_parent"
android:layout_height="300dp"
android:background="?attr/card_image_background" />
<LinearLayout
android:id="@+id/card_info"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingBottom="6dp"
android:paddingLeft="6dp"
android:paddingRight="6dp"
android:paddingTop="6dp">
<TextView
android:id="@+id/card_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:ellipsize="end"
android:fontFamily="sans-serif-medium"
android:lines="1"
android:textColor="?android:textColorPrimary" />
<TextView
android:id="@+id/card_description"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:ellipsize="end"
android:lineSpacingMultiplier="1.1"
android:maxLines="2"
android:textColor="?android:textColorSecondary" />
<TextView
android:id="@+id/card_link"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:lines="1"
android:textColor="?android:textColorTertiary" />
</LinearLayout>
</LinearLayout>
<FrameLayout
android:id="@+id/status_media_preview_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/status_content"
android:layout_below="@+id/card_view"
android:layout_toEndOf="@+id/status_avatar"
android:layout_toRightOf="@+id/status_avatar">

View file

@ -39,5 +39,7 @@
<attr name="compose_image_button_tint" format="reference|color" />
<attr name="report_status_background_color" format="reference|color" />
<attr name="report_status_divider_drawable" format="reference" />
<attr name="card_background" format="reference|color" />
<attr name="card_image_background" format="reference|color" />
</resources>

View file

@ -21,4 +21,8 @@
<dimen name="status_left_line_margin">38dp</dimen>
<dimen name="text_content_margin">16dp</dimen>
<dimen name="status_sensitive_media_button_padding">5dp</dimen>
<dimen name="card_image_vertical_height">160dp</dimen>
<dimen name="card_image_horizontal_width">100dp</dimen>
</resources>

View file

@ -80,6 +80,10 @@
<item name="material_drawer_selected">@color/color_primary_dark</item>
<item name="material_drawer_selected_text">@color/text_color_primary_dark</item>
<item name="material_drawer_header_selection_text">@color/text_color_primary_dark</item>
<item name="card_background">@drawable/card_frame_dark</item>
<item name="card_image_background">@color/text_color_tertiary_dark</item>
</style>
<style name="AppTheme.ImageButton.Dark" parent="@style/Widget.AppCompat.Button.Borderless.Colored">
@ -156,6 +160,10 @@
<item name="material_drawer_selected">@color/color_primary_light</item>
<item name="material_drawer_selected_text">@color/text_color_primary_light</item>
<item name="material_drawer_header_selection_text">@color/text_color_primary_dark</item> <!--Intentionally dark so it can be overlayed over the account's header image.-->
<item name="card_background">@drawable/card_frame_light</item>
<item name="card_image_background">@color/text_color_tertiary_light</item>
</style>
<style name="AppTheme.ImageButton.Light" parent="Widget.AppCompat.Button.Borderless.Colored">