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
main
Levi Bard 7 years ago committed by Konrad Pozniak
parent 9ae7c385ca
commit 51b94b876f
  1. 5
      app/src/main/java/com/keylesspalace/tusky/AccountActivity.java
  2. 6
      app/src/main/java/com/keylesspalace/tusky/SearchActivity.java
  3. 3
      app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java
  4. 160
      app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java
  5. 2
      app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java
  6. 3
      app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java
  7. 1
      app/src/main/java/com/keylesspalace/tusky/interfaces/LinkListener.java
  8. 11
      app/src/main/java/com/keylesspalace/tusky/util/ClickableSpanNoUnderline.kt
  9. 41
      app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java
  10. 10
      app/src/main/res/layout/fragment_timeline.xml
  11. 9
      app/src/main/res/layout/fragment_view_thread.xml
  12. 21
      app/src/main/res/layout/item_status_bottom_sheet.xml
  13. 1
      app/src/main/res/values/strings.xml

@ -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()) {

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

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

@ -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,11 +230,13 @@ public abstract class SFragment extends BaseFragment implements AdapterItemRemov
}
protected void viewThread(Status status) {
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) {
Intent intent = new Intent(getContext(), ViewTagActivity.class);
@ -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<SearchResults> call = mastodonApi.search(url, true);
call.enqueue(new Callback<SearchResults>() {
@Override
public void onResponse(@NonNull Call<SearchResults> call, @NonNull Response<SearchResults> 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<Status> statuses = response.body().getStatuses();
List<Account> 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<SearchResults> 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);
}
}

@ -99,8 +99,6 @@ public class TimelineFragment extends SFragment implements
@Inject
TimelineCases timelineCases;
@Inject
MastodonApi mastodonApi;
private SwipeRefreshLayout swipeRefreshLayout;
private TimelineAdapter adapter;

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

@ -18,4 +18,5 @@ package com.keylesspalace.tusky.interfaces;
public interface LinkListener {
void onViewTag(String tag);
void onViewAccount(String id);
void onViewURL(String url);
}

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

@ -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);
public void onClick(View widget) { listener.onViewAccount(accountId); }
};
}
}
@Override public void updateDrawState(TextPaint ds) {
super.updateDrawState(ds);
ds.setUnderlineText(false);
if (customSpan == null) {
customSpan = new CustomURLSpan(span.getURL()) {
@Override
public void onClick(View widget) {
listener.onViewURL(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);
}
} else {
ClickableSpan newSpan = new CustomURLSpan(span.getURL());
builder.removeSpan(span);
builder.setSpan(newSpan, start, end, flags);
}
builder.setSpan(customSpan, start, end, flags);
}
view.setText(builder);
view.setLinksClickable(true);

@ -1,13 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_height="match_parent"
android:layout_width="match_parent">
<android.support.v4.widget.SwipeRefreshLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/swipe_refresh_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="match_parent"
android:layout_gravity="top">
<android.support.v7.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</android.support.v4.widget.SwipeRefreshLayout>
<include layout="@layout/item_status_bottom_sheet"/>
</android.support.design.widget.CoordinatorLayout>

@ -1,8 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_height="match_parent"
android:layout_width="match_parent">
<android.support.v4.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/swipe_refresh_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="match_parent"
android:layout_gravity="top">
<android.support.v7.widget.RecyclerView
android:id="@+id/recycler_view"
@ -10,3 +15,5 @@
android:layout_height="match_parent"
android:scrollbars="vertical" />
</android.support.v4.widget.SwipeRefreshLayout>
<include layout="@layout/item_status_bottom_sheet" />
</android.support.design.widget.CoordinatorLayout>

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/item_status_bottom_sheet"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="80dp"
android:layout_gravity="bottom"
android:background="?android:colorBackground"
app:behavior_hideable="true"
app:layout_behavior="android.support.design.widget.BottomSheetBehavior"
>
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="@string/performing_lookup_title"
android:textSize="?attr/status_text_medium"
android:textColor="?android:textColorPrimary"
/>
</LinearLayout>

@ -303,5 +303,6 @@
<string name="error_no_custom_emojis">Your instance %s does not have any custom emojis</string>
<string name="copy_to_clipboard_success">Copied to clipboard</string>
<string name="performing_lookup_title">Performing lookup...</string>
</resources>

Loading…
Cancel
Save