From 51b94b876fb19fdc0c0da9eb0492e18b8604b4d4 Mon Sep 17 00:00:00 2001 From: Levi Bard Date: Wed, 25 Apr 2018 20:04:55 +0200 Subject: [PATCH] View links to statuses inside Tusky (#568) * View links to statuses inside Tusky * Only attempt to open links that look like mastodon statuses * Add support for pleroma statuses * Move "smells like mastodon" url check to click handler * Add bottom sheet to notify users of post query status * Improve architecture for managing search status * Push everything into SFragment * Add external lookup for non-locally-resolved account links * Clean up copypasta from LinkHelper.setClickableText * Apply PR feedback * Migrate bottom sheet wrappers to CoordinatorLayout --- .../keylesspalace/tusky/AccountActivity.java | 5 + .../keylesspalace/tusky/SearchActivity.java | 6 + .../tusky/fragment/NotificationsFragment.java | 3 - .../tusky/fragment/SFragment.java | 168 +++++++++++++++++- .../tusky/fragment/TimelineFragment.java | 2 - .../tusky/fragment/ViewThreadFragment.java | 3 - .../tusky/interfaces/LinkListener.java | 1 + .../tusky/util/ClickableSpanNoUnderline.kt | 11 ++ .../keylesspalace/tusky/util/LinkHelper.java | 45 ++--- app/src/main/res/layout/fragment_timeline.xml | 24 ++- .../main/res/layout/fragment_view_thread.xml | 25 ++- .../res/layout/item_status_bottom_sheet.xml | 21 +++ app/src/main/res/values/strings.xml | 1 + 13 files changed, 257 insertions(+), 58 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/ClickableSpanNoUnderline.kt create mode 100644 app/src/main/res/layout/item_status_bottom_sheet.xml diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountActivity.java b/app/src/main/java/com/keylesspalace/tusky/AccountActivity.java index ef8e93d8..a13e796c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/AccountActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/AccountActivity.java @@ -329,6 +329,11 @@ public final class AccountActivity extends BaseActivity implements ActionButtonA intent.putExtra("id", id); startActivity(intent); } + + @Override + public void onViewURL(String url) { + LinkHelper.openLink(url, note.getContext()); + } }); if (account.getLocked()) { diff --git a/app/src/main/java/com/keylesspalace/tusky/SearchActivity.java b/app/src/main/java/com/keylesspalace/tusky/SearchActivity.java index 5c37c598..c45b80da 100644 --- a/app/src/main/java/com/keylesspalace/tusky/SearchActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/SearchActivity.java @@ -39,6 +39,7 @@ import com.keylesspalace.tusky.di.Injectable; import com.keylesspalace.tusky.entity.SearchResults; import com.keylesspalace.tusky.interfaces.LinkListener; import com.keylesspalace.tusky.network.MastodonApi; +import com.keylesspalace.tusky.util.LinkHelper; import javax.inject.Inject; @@ -141,6 +142,11 @@ public class SearchActivity extends BaseActivity implements SearchView.OnQueryTe startActivity(intent); } + @Override + public void onViewURL(String url) { + LinkHelper.openLink(url, getApplicationContext()); + } + private void handleIntent(Intent intent) { if (Intent.ACTION_SEARCH.equals(intent.getAction())) { currentQuery = intent.getStringExtra(SearchManager.QUERY); diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java index d7b068fc..bd5a1fd1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -48,7 +48,6 @@ import com.keylesspalace.tusky.entity.Notification; import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.interfaces.ActionButtonActivity; import com.keylesspalace.tusky.interfaces.StatusActionListener; -import com.keylesspalace.tusky.network.MastodonApi; import com.keylesspalace.tusky.network.TimelineCases; import com.keylesspalace.tusky.receiver.TimelineReceiver; import com.keylesspalace.tusky.util.CollectionUtil; @@ -106,8 +105,6 @@ public class NotificationsFragment extends SFragment implements @Inject public TimelineCases timelineCases; @Inject - public MastodonApi mastodonApi; - @Inject AccountManager accountManager; private SwipeRefreshLayout swipeRefreshLayout; diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java index 065fc28b..e1f369da 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java @@ -20,12 +20,15 @@ import android.content.ClipboardManager; import android.content.Context; import android.content.Intent; import android.os.Bundle; +import android.support.annotation.NonNull; import android.support.annotation.Nullable; +import android.support.design.widget.BottomSheetBehavior; import android.support.v4.app.ActivityOptionsCompat; import android.support.v4.view.ViewCompat; import android.support.v7.widget.PopupMenu; import android.text.Spanned; import android.view.View; +import android.widget.LinearLayout; import com.keylesspalace.tusky.AccountActivity; import com.keylesspalace.tusky.ComposeActivity; @@ -38,15 +41,28 @@ import com.keylesspalace.tusky.ViewThreadActivity; import com.keylesspalace.tusky.ViewVideoActivity; import com.keylesspalace.tusky.db.AccountEntity; import com.keylesspalace.tusky.db.AccountManager; +import com.keylesspalace.tusky.entity.Account; import com.keylesspalace.tusky.entity.Attachment; +import com.keylesspalace.tusky.entity.SearchResults; import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.interfaces.AdapterItemRemover; +import com.keylesspalace.tusky.network.MastodonApi; import com.keylesspalace.tusky.network.TimelineCases; import com.keylesspalace.tusky.util.HtmlUtils; +import com.keylesspalace.tusky.util.LinkHelper; +import java.net.URI; +import java.net.URISyntaxException; import java.util.LinkedHashSet; +import java.util.List; import java.util.Set; +import javax.inject.Inject; + +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; + /* Note from Andrew on Jan. 22, 2017: This class is a design problem for me, so I left it with an * awkward name. TimelineFragment and NotificationFragment have significant overlap but the nature * of that is complicated by how they're coupled with Status and Notification and the corresponding @@ -58,8 +74,13 @@ public abstract class SFragment extends BaseFragment implements AdapterItemRemov protected String loggedInAccountId; protected String loggedInUsername; + protected String searchUrl; protected abstract TimelineCases timelineCases(); + protected BottomSheetBehavior bottomSheet; + + @Inject + protected MastodonApi mastodonApi; @Override public void onActivityCreated(@Nullable Bundle savedInstanceState) { @@ -70,6 +91,7 @@ public abstract class SFragment extends BaseFragment implements AdapterItemRemov loggedInAccountId = activeAccount.getAccountId(); loggedInUsername = activeAccount.getUsername(); } + setupBottomSheet(getView()); } @Override @@ -208,10 +230,12 @@ public abstract class SFragment extends BaseFragment implements AdapterItemRemov } protected void viewThread(Status status) { - Intent intent = new Intent(getContext(), ViewThreadActivity.class); - intent.putExtra("id", status.getActionableId()); - intent.putExtra("url", status.getActionableStatus().getUrl()); - startActivity(intent); + if (!isSearching()) { + Intent intent = new Intent(getContext(), ViewThreadActivity.class); + intent.putExtra("id", status.getActionableId()); + intent.putExtra("url", status.getActionableStatus().getUrl()); + startActivity(intent); + } } protected void viewTag(String tag) { @@ -235,4 +259,140 @@ public abstract class SFragment extends BaseFragment implements AdapterItemRemov intent.putExtra("status_content", HtmlUtils.toHtml(statusContent)); startActivity(intent); } + + // https://mastodon.foo.bar/@User + // https://mastodon.foo.bar/@User/43456787654678 + // https://pleroma.foo.bar/users/User + // https://pleroma.foo.bar/users/43456787654678 + // https://pleroma.foo.bar/notice/43456787654678 + // https://pleroma.foo.bar/objects/d4643c42-3ae0-4b73-b8b0-c725f5819207 + private static boolean looksLikeMastodonUrl(String urlString) { + URI uri; + try { + uri = new URI(urlString); + } catch (URISyntaxException e) { + return false; + } + + if (uri.getQuery() != null || + uri.getFragment() != null || + uri.getPath() == null) { + return false; + } + + String path = uri.getPath(); + return path.matches("^/@[^/]*$") || + path.matches("^/users/[^/]+$") || + path.matches("^/(@|notice)[^/]*/\\d+$") || + path.matches("^/objects/[-a-f0-9]+$"); + } + + private void onBeginSearch(@NonNull String url) { + searchUrl = url; + showQuerySheet(); + } + + private boolean getCancelSearchRequested(@NonNull String url) { + return !url.equals(searchUrl); + } + + private boolean isSearching() { + return searchUrl != null; + } + + private void onEndSearch(@NonNull String url) { + if (url.equals(searchUrl)) { + // Don't clear query if there's no match, + // since we might just now be getting the response for a canceled search + searchUrl = null; + hideQuerySheet(); + } + } + + private void cancelActiveSearch() + { + if (isSearching()) { + onEndSearch(searchUrl); + } + } + + public void onViewURL(String url) { + if (!looksLikeMastodonUrl(url)) { + LinkHelper.openLink(url, getContext()); + return; + } + + Call call = mastodonApi.search(url, true); + call.enqueue(new Callback() { + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) { + if (getCancelSearchRequested(url)) { + return; + } + + onEndSearch(url); + if (response.isSuccessful()) { + // According to the mastodon API doc, if the search query is a url, + // only exact matches for statuses or accounts are returned + // which is good, because pleroma returns a different url + // than the public post link + List statuses = response.body().getStatuses(); + List accounts = response.body().getAccounts(); + if (statuses != null && !statuses.isEmpty()) { + viewThread(statuses.get(0)); + return; + } else if (accounts != null && !accounts.isEmpty()) { + viewAccount(accounts.get(0).getId()); + return; + } + } + LinkHelper.openLink(url, getContext()); + } + + @Override + public void onFailure(@NonNull Call call, @NonNull Throwable t) { + if (!getCancelSearchRequested(url)) { + onEndSearch(url); + LinkHelper.openLink(url, getContext()); + } + } + }); + callList.add(call); + onBeginSearch(url); + } + + protected void setupBottomSheet(View view) + { + LinearLayout bottomSheetLayout = view.findViewById(R.id.item_status_bottom_sheet); + if (bottomSheetLayout != null) { + bottomSheet = BottomSheetBehavior.from(bottomSheetLayout); + bottomSheet.setState(BottomSheetBehavior.STATE_HIDDEN); + bottomSheet.setBottomSheetCallback(new BottomSheetBehavior.BottomSheetCallback() { + @Override + public void onStateChanged(@NonNull View bottomSheet, int newState) { + switch(newState) { + case BottomSheetBehavior.STATE_HIDDEN: + cancelActiveSearch(); + break; + default: + break; + } + } + + @Override + public void onSlide(@NonNull View bottomSheet, float slideOffset) { + } + }); + } + } + + private void showQuerySheet() { + if (bottomSheet != null) + bottomSheet.setState(BottomSheetBehavior.STATE_COLLAPSED); + } + + private void hideQuerySheet() { + if (bottomSheet != null) + bottomSheet.setState(BottomSheetBehavior.STATE_HIDDEN); + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java index afb12460..fdeca9a0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java @@ -99,8 +99,6 @@ public class TimelineFragment extends SFragment implements @Inject TimelineCases timelineCases; - @Inject - MastodonApi mastodonApi; private SwipeRefreshLayout swipeRefreshLayout; private TimelineAdapter adapter; 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 24c661e6..d7d9f9d0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java @@ -43,7 +43,6 @@ import com.keylesspalace.tusky.entity.Card; import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.entity.StatusContext; import com.keylesspalace.tusky.interfaces.StatusActionListener; -import com.keylesspalace.tusky.network.MastodonApi; import com.keylesspalace.tusky.network.TimelineCases; import com.keylesspalace.tusky.receiver.TimelineReceiver; import com.keylesspalace.tusky.util.PairedList; @@ -68,8 +67,6 @@ public class ViewThreadFragment extends SFragment implements @Inject public TimelineCases timelineCases; - @Inject - public MastodonApi mastodonApi; private SwipeRefreshLayout swipeRefreshLayout; private RecyclerView recyclerView; diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/LinkListener.java b/app/src/main/java/com/keylesspalace/tusky/interfaces/LinkListener.java index 62360e34..cb001869 100644 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/LinkListener.java +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/LinkListener.java @@ -18,4 +18,5 @@ package com.keylesspalace.tusky.interfaces; public interface LinkListener { void onViewTag(String tag); void onViewAccount(String id); + void onViewURL(String url); } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ClickableSpanNoUnderline.kt b/app/src/main/java/com/keylesspalace/tusky/util/ClickableSpanNoUnderline.kt new file mode 100644 index 00000000..bd4bceb3 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/ClickableSpanNoUnderline.kt @@ -0,0 +1,11 @@ +package com.keylesspalace.tusky.util + +import android.text.TextPaint +import android.text.style.ClickableSpan + +abstract class ClickableSpanNoUnderline : ClickableSpan() { + override fun updateDrawState(ds: TextPaint?) { + super.updateDrawState(ds) + ds?.isUnderlineText = false; + } +} \ No newline at end of file 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 eae6c9d4..8585d3a4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java @@ -74,20 +74,14 @@ public class LinkHelper { int end = builder.getSpanEnd(span); int flags = builder.getSpanFlags(span); CharSequence text = builder.subSequence(start, end); + ClickableSpan customSpan = null; + if (text.charAt(0) == '#') { final String tag = text.subSequence(1, text.length()).toString(); - ClickableSpan newSpan = new ClickableSpan() { + customSpan = new ClickableSpanNoUnderline() { @Override - public void onClick(View widget) { - listener.onViewTag(tag); - } - @Override public void updateDrawState(TextPaint ds) { - super.updateDrawState(ds); - ds.setUnderlineText(false); - } + public void onClick(View widget) { listener.onViewTag(tag); } }; - builder.removeSpan(span); - builder.setSpan(newSpan, start, end, flags); } else if (text.charAt(0) == '@' && mentions != null && mentions.length > 0) { String accountUsername = text.subSequence(1, text.length()).toString(); /* There may be multiple matches for users on different instances with the same @@ -104,28 +98,23 @@ public class LinkHelper { } if (id != null) { final String accountId = id; - ClickableSpan newSpan = new ClickableSpan() { + customSpan = new ClickableSpanNoUnderline() { @Override - public void onClick(View widget) { - listener.onViewAccount(accountId); - } - @Override public void updateDrawState(TextPaint ds) { - super.updateDrawState(ds); - ds.setUnderlineText(false); - } + public void onClick(View widget) { listener.onViewAccount(accountId); } }; - builder.removeSpan(span); - builder.setSpan(newSpan, start, end, flags); - } else { - ClickableSpan newSpan = new CustomURLSpan(span.getURL()); - builder.removeSpan(span); - builder.setSpan(newSpan, start, end, flags); } - } else { - ClickableSpan newSpan = new CustomURLSpan(span.getURL()); - builder.removeSpan(span); - builder.setSpan(newSpan, start, end, flags); } + + if (customSpan == null) { + customSpan = new CustomURLSpan(span.getURL()) { + @Override + public void onClick(View widget) { + listener.onViewURL(getURL()); + } + }; + } + builder.removeSpan(span); + builder.setSpan(customSpan, start, end, flags); } view.setText(builder); view.setLinksClickable(true); diff --git a/app/src/main/res/layout/fragment_timeline.xml b/app/src/main/res/layout/fragment_timeline.xml index e19ddd74..b743e8f5 100644 --- a/app/src/main/res/layout/fragment_timeline.xml +++ b/app/src/main/res/layout/fragment_timeline.xml @@ -1,13 +1,19 @@ - - - + + android:layout_height="match_parent" + android:layout_gravity="top"> - \ No newline at end of file + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_view_thread.xml b/app/src/main/res/layout/fragment_view_thread.xml index 03119b4f..df0dd975 100644 --- a/app/src/main/res/layout/fragment_view_thread.xml +++ b/app/src/main/res/layout/fragment_view_thread.xml @@ -1,12 +1,19 @@ - - - + - \ No newline at end of file + android:layout_gravity="top"> + + + + + diff --git a/app/src/main/res/layout/item_status_bottom_sheet.xml b/app/src/main/res/layout/item_status_bottom_sheet.xml new file mode 100644 index 00000000..5a7c8b67 --- /dev/null +++ b/app/src/main/res/layout/item_status_bottom_sheet.xml @@ -0,0 +1,21 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2b7213a6..64f15d29 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -303,5 +303,6 @@ Your instance %s does not have any custom emojis Copied to clipboard + Performing lookup...