diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java index d264361a..4098e325 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java @@ -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); + } + + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/ThreadAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/ThreadAdapter.java index 48c893a2..46481792 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/ThreadAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/ThreadAdapter.java @@ -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<>(); diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Card.java b/app/src/main/java/com/keylesspalace/tusky/entity/Card.java new file mode 100644 index 00000000..1582be0a --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Card.java @@ -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 . */ + +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 CREATOR = new Creator() { + @Override + public Card createFromParcel(Parcel source) { + return new Card(source); + } + + @Override + public Card[] newArray(int size) { + return new Card[size]; + } + }; +} diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java index 43b4423a..f186bb9a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java @@ -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 call = mastodonApi.statusCard(id); + call.enqueue(new Callback() { + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) { + if (response.isSuccessful()) { + showCard(response.body()); + } else { + onThreadRequestFailure(id); + } + } + + @Override + public void onFailure(@NonNull Call 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(); diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java index 8a039fa8..78003e1e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java @@ -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 statusCard( + @Path("id") String statusId + ); } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/CustomTabURLSpan.java b/app/src/main/java/com/keylesspalace/tusky/util/CustomTabURLSpan.java index 0be8a978..2a25120b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/CustomTabURLSpan.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/CustomTabURLSpan.java @@ -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()); } } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java index ab1ad6aa..33445622 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java @@ -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()); + } + + } + + + } diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java index 32d526e6..a7b31159 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java @@ -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 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 emojis) { + Status.Application application, List 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 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); } } } diff --git a/app/src/main/res/drawable/card_frame_dark.xml b/app/src/main/res/drawable/card_frame_dark.xml new file mode 100644 index 00000000..e68b196b --- /dev/null +++ b/app/src/main/res/drawable/card_frame_dark.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/card_frame_light.xml b/app/src/main/res/drawable/card_frame_light.xml new file mode 100644 index 00000000..a7f15ab5 --- /dev/null +++ b/app/src/main/res/drawable/card_frame_light.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_status_detailed.xml b/app/src/main/res/layout/item_status_detailed.xml index 07f6d33f..9e5ddcf9 100644 --- a/app/src/main/res/layout/item_status_detailed.xml +++ b/app/src/main/res/layout/item_status_detailed.xml @@ -104,11 +104,72 @@ android:textAppearance="@android:style/TextAppearance.DeviceDefault.Medium" android:textColor="?android:textColorPrimary" /> + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 4f912a7e..4d2dd849 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -39,5 +39,7 @@ + + \ No newline at end of file diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 9fc97df0..f2cb9700 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -21,4 +21,8 @@ 38dp 16dp 5dp + + 160dp + 100dp + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index cbf79699..64e5480f 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -80,6 +80,10 @@ @color/color_primary_dark @color/text_color_primary_dark @color/text_color_primary_dark + + @drawable/card_frame_dark + @color/text_color_tertiary_dark +