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
This commit is contained in:
parent
3f71c5495f
commit
76eae44324
13 changed files with 257 additions and 58 deletions
|
@ -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); }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (customSpan == null) {
|
||||
customSpan = new CustomURLSpan(span.getURL()) {
|
||||
@Override
|
||||
public void onClick(View widget) {
|
||||
listener.onViewAccount(accountId);
|
||||
}
|
||||
@Override public void updateDrawState(TextPaint ds) {
|
||||
super.updateDrawState(ds);
|
||||
ds.setUnderlineText(false);
|
||||
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>
|
||||
|
|
21
app/src/main/res/layout/item_status_bottom_sheet.xml
Normal file
21
app/src/main/res/layout/item_status_bottom_sheet.xml
Normal file
|
@ -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…
Reference in a new issue