From dbb266388216f793ae6cf0a38a333f14094b59dc Mon Sep 17 00:00:00 2001
From: Vavassor <copernicus-@hotmail.com>
Date: Thu, 26 Jan 2017 19:34:32 -0500
Subject: [PATCH] Links and tags in statuses are now clickable and open
 suitable pages.

Mentions are also, incidentally, but still link to the account page for that user in the browser. This should be changed to an in-app account page when that's finished, but it's actually fairly suitable fallback behaviour for now.
---
 app/src/main/AndroidManifest.xml              |  1 +
 .../tusky/NotificationsFragment.java          |  4 ++
 .../com/keylesspalace/tusky/SFragment.java    |  6 ++
 .../tusky/StatusActionListener.java           |  1 +
 .../keylesspalace/tusky/StatusViewHolder.java | 41 +++++++++++--
 .../keylesspalace/tusky/TimelineFragment.java | 60 ++++++++++++++-----
 .../keylesspalace/tusky/ViewTagActivity.java  | 46 ++++++++++++++
 .../tusky/ViewThreadFragment.java             |  4 ++
 app/src/main/res/layout/activity_view_tag.xml | 38 ++++++++++++
 app/src/main/res/values/strings.xml           |  3 +
 10 files changed, 185 insertions(+), 19 deletions(-)
 create mode 100644 app/src/main/java/com/keylesspalace/tusky/ViewTagActivity.java
 create mode 100644 app/src/main/res/layout/activity_view_tag.xml

diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 5467b865..3053a174 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -34,6 +34,7 @@
             android:windowSoftInputMode="stateVisible|adjustResize" />
         <activity android:name=".ViewVideoActivity" />
         <activity android:name=".ViewThreadActivity" />
+        <activity android:name=".ViewTagActivity" />
         <service
             android:name=".NotificationService"
             android:description="@string/notification_service_description"
diff --git a/app/src/main/java/com/keylesspalace/tusky/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/NotificationsFragment.java
index eb07aeda..5827a517 100644
--- a/app/src/main/java/com/keylesspalace/tusky/NotificationsFragment.java
+++ b/app/src/main/java/com/keylesspalace/tusky/NotificationsFragment.java
@@ -198,4 +198,8 @@ public class NotificationsFragment extends SFragment implements
         Notification notification = adapter.getItem(position);
         super.viewThread(notification.getStatus());
     }
+
+    public void onViewTag(String tag) {
+        super.viewTag(tag);
+    }
 }
diff --git a/app/src/main/java/com/keylesspalace/tusky/SFragment.java b/app/src/main/java/com/keylesspalace/tusky/SFragment.java
index 230e73bd..23a0ac4e 100644
--- a/app/src/main/java/com/keylesspalace/tusky/SFragment.java
+++ b/app/src/main/java/com/keylesspalace/tusky/SFragment.java
@@ -244,4 +244,10 @@ public class SFragment extends Fragment {
         intent.putExtra("id", status.getId());
         startActivity(intent);
     }
+
+    protected void viewTag(String tag) {
+        Intent intent = new Intent(getContext(), ViewTagActivity.class);
+        intent.putExtra("hashtag", tag);
+        startActivity(intent);
+    }
 }
diff --git a/app/src/main/java/com/keylesspalace/tusky/StatusActionListener.java b/app/src/main/java/com/keylesspalace/tusky/StatusActionListener.java
index 85852384..b23b373d 100644
--- a/app/src/main/java/com/keylesspalace/tusky/StatusActionListener.java
+++ b/app/src/main/java/com/keylesspalace/tusky/StatusActionListener.java
@@ -24,4 +24,5 @@ public interface StatusActionListener {
     void onMore(View view, final int position);
     void onViewMedia(String url, Status.MediaAttachment.Type type);
     void onViewThread(int position);
+    void onViewTag(String tag);
 }
diff --git a/app/src/main/java/com/keylesspalace/tusky/StatusViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/StatusViewHolder.java
index 49737166..629c57c5 100644
--- a/app/src/main/java/com/keylesspalace/tusky/StatusViewHolder.java
+++ b/app/src/main/java/com/keylesspalace/tusky/StatusViewHolder.java
@@ -18,7 +18,13 @@ package com.keylesspalace.tusky;
 import android.content.Context;
 import android.support.annotation.Nullable;
 import android.support.v7.widget.RecyclerView;
+import android.text.SpannableStringBuilder;
 import android.text.Spanned;
+import android.text.method.LinkMovementMethod;
+import android.text.method.MovementMethod;
+import android.text.style.ClickableSpan;
+import android.text.style.URLSpan;
+import android.text.util.Linkify;
 import android.view.View;
 import android.view.ViewGroup;
 import android.widget.ImageButton;
@@ -28,7 +34,10 @@ import android.widget.TextView;
 import com.android.volley.toolbox.ImageLoader;
 import com.android.volley.toolbox.NetworkImageView;
 
+import java.net.URL;
 import java.util.Date;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 public class StatusViewHolder extends RecyclerView.ViewHolder {
     private View container;
@@ -89,8 +98,32 @@ public class StatusViewHolder extends RecyclerView.ViewHolder {
         username.setText(usernameText);
     }
 
-    public void setContent(Spanned content) {
-        this.content.setText(content);
+    public void setContent(Spanned content, final StatusActionListener listener) {
+        // Redirect URLSpan's in the status content to the listener for viewing tag pages.
+        SpannableStringBuilder builder = new SpannableStringBuilder(content);
+        URLSpan[] urlSpans = content.getSpans(0, content.length(), URLSpan.class);
+        for (URLSpan span : urlSpans) {
+            int start = builder.getSpanStart(span);
+            int end = builder.getSpanEnd(span);
+            int flags = builder.getSpanFlags(span);
+            CharSequence tag = builder.subSequence(start, end);
+            if (tag.charAt(0) == '#') {
+                final String viewTag = tag.subSequence(1, tag.length()).toString();
+                ClickableSpan newSpan = new ClickableSpan() {
+                    @Override
+                    public void onClick(View widget) {
+                        listener.onViewTag(viewTag);
+                    }
+                };
+                builder.removeSpan(span);
+                builder.setSpan(newSpan, start, end, flags);
+            }
+        }
+        // Set the contents.
+        this.content.setText(builder);
+        // Make links clickable.
+        this.content.setLinksClickable(true);
+        this.content.setMovementMethod(LinkMovementMethod.getInstance());
     }
 
     public void setAvatar(String url) {
@@ -203,6 +236,7 @@ public class StatusViewHolder extends RecyclerView.ViewHolder {
     }
 
     public void setupButtons(final StatusActionListener listener, final int position) {
+
         replyButton.setOnClickListener(new View.OnClickListener() {
             @Override
             public void onClick(View v) {
@@ -239,9 +273,8 @@ public class StatusViewHolder extends RecyclerView.ViewHolder {
         setDisplayName(status.getDisplayName());
         setUsername(status.getUsername());
         setCreatedAt(status.getCreatedAt());
-        setContent(status.getContent());
+        setContent(status.getContent(), listener);
         setAvatar(status.getAvatar());
-        setContent(status.getContent());
         setReblogged(status.getReblogged());
         setFavourited(status.getFavourited());
         String rebloggedByUsername = status.getRebloggedByUsername();
diff --git a/app/src/main/java/com/keylesspalace/tusky/TimelineFragment.java b/app/src/main/java/com/keylesspalace/tusky/TimelineFragment.java
index d3958dda..b8575baa 100644
--- a/app/src/main/java/com/keylesspalace/tusky/TimelineFragment.java
+++ b/app/src/main/java/com/keylesspalace/tusky/TimelineFragment.java
@@ -47,12 +47,14 @@ public class TimelineFragment extends SFragment implements
         HOME,
         MENTIONS,
         PUBLIC,
+        TAG,
     }
 
     private SwipeRefreshLayout swipeRefreshLayout;
     private RecyclerView recyclerView;
     private TimelineAdapter adapter;
     private Kind kind;
+    private String hashtag;
     private LinearLayoutManager layoutManager;
     private EndlessOnScrollListener scrollListener;
     private TabLayout.OnTabSelectedListener onTabSelectedListener;
@@ -65,11 +67,24 @@ public class TimelineFragment extends SFragment implements
         return fragment;
     }
 
+    public static TimelineFragment newInstance(Kind kind, String hashtag) {
+        TimelineFragment fragment = new TimelineFragment();
+        Bundle arguments = new Bundle();
+        arguments.putString("kind", kind.name());
+        arguments.putString("hashtag", hashtag);
+        fragment.setArguments(arguments);
+        return fragment;
+    }
+
     @Override
     public View onCreateView(LayoutInflater inflater, ViewGroup container,
              Bundle savedInstanceState) {
 
-        kind = Kind.valueOf(getArguments().getString("kind"));
+        Bundle arguments = getArguments();
+        kind = Kind.valueOf(arguments.getString("kind"));
+        if (kind == Kind.TAG) {
+            hashtag = arguments.getString("hashtag");
+        }
 
         View rootView = inflater.inflate(R.layout.fragment_timeline, container, false);
 
@@ -103,20 +118,24 @@ public class TimelineFragment extends SFragment implements
         adapter = new TimelineAdapter(this, this);
         recyclerView.setAdapter(adapter);
 
-        TabLayout layout = (TabLayout) getActivity().findViewById(R.id.tab_layout);
-        onTabSelectedListener = new TabLayout.OnTabSelectedListener() {
-            @Override
-            public void onTabSelected(TabLayout.Tab tab) {}
+        if (kind != Kind.TAG) {
+            TabLayout layout = (TabLayout) getActivity().findViewById(R.id.tab_layout);
+            onTabSelectedListener = new TabLayout.OnTabSelectedListener() {
+                @Override
+                public void onTabSelected(TabLayout.Tab tab) {
+                }
 
-            @Override
-            public void onTabUnselected(TabLayout.Tab tab) {}
+                @Override
+                public void onTabUnselected(TabLayout.Tab tab) {
+                }
 
-            @Override
-            public void onTabReselected(TabLayout.Tab tab) {
-                jumpToTop();
-            }
-        };
-        layout.addOnTabSelectedListener(onTabSelectedListener);
+                @Override
+                public void onTabReselected(TabLayout.Tab tab) {
+                    jumpToTop();
+                }
+            };
+            layout.addOnTabSelectedListener(onTabSelectedListener);
+        }
 
         sendFetchTimelineRequest();
 
@@ -125,8 +144,10 @@ public class TimelineFragment extends SFragment implements
 
     @Override
     public void onDestroyView() {
-        TabLayout tabLayout = (TabLayout) getActivity().findViewById(R.id.tab_layout);
-        tabLayout.removeOnTabSelectedListener(onTabSelectedListener);
+        if (kind != Kind.TAG) {
+            TabLayout tabLayout = (TabLayout) getActivity().findViewById(R.id.tab_layout);
+            tabLayout.removeOnTabSelectedListener(onTabSelectedListener);
+        }
         super.onDestroyView();
     }
 
@@ -151,6 +172,11 @@ public class TimelineFragment extends SFragment implements
                 endpoint = getString(R.string.endpoint_timelines_public);
                 break;
             }
+            case TAG: {
+                assert(hashtag != null);
+                endpoint = String.format(getString(R.string.endpoint_timelines_tag), hashtag);
+                break;
+            }
         }
         String url = "https://" + domain + endpoint;
         if (fromId != null) {
@@ -250,4 +276,8 @@ public class TimelineFragment extends SFragment implements
     public void onViewThread(int position) {
         super.viewThread(adapter.getItem(position));
     }
+
+    public void onViewTag(String tag) {
+        super.viewTag(tag);
+    }
 }
diff --git a/app/src/main/java/com/keylesspalace/tusky/ViewTagActivity.java b/app/src/main/java/com/keylesspalace/tusky/ViewTagActivity.java
new file mode 100644
index 00000000..dd3c26e8
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/ViewTagActivity.java
@@ -0,0 +1,46 @@
+/* Copyright 2017 Andrew Dawson
+ *
+ * This file is part of Tusky.
+ *
+ * Tusky 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;
+
+import android.os.Bundle;
+import android.support.annotation.Nullable;
+import android.support.v4.app.Fragment;
+import android.support.v4.app.FragmentTransaction;
+import android.support.v7.app.ActionBar;
+import android.support.v7.app.AppCompatActivity;
+import android.support.v7.widget.Toolbar;
+
+public class ViewTagActivity extends AppCompatActivity {
+    @Override
+    protected void onCreate(@Nullable Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setContentView(R.layout.activity_view_tag);
+
+        String hashtag = getIntent().getStringExtra("hashtag");
+
+        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
+        setSupportActionBar(toolbar);
+        ActionBar bar = getSupportActionBar();
+        if (bar != null) {
+            bar.setTitle(String.format(getString(R.string.title_tag), hashtag));
+        }
+
+        FragmentTransaction fragmentTransaction = getSupportFragmentManager().beginTransaction();
+        Fragment fragment = TimelineFragment.newInstance(TimelineFragment.Kind.TAG, hashtag);
+        fragmentTransaction.add(R.id.fragment_container, fragment);
+        fragmentTransaction.commit();
+    }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/ViewThreadFragment.java b/app/src/main/java/com/keylesspalace/tusky/ViewThreadFragment.java
index 9dbfd61a..ccc6e392 100644
--- a/app/src/main/java/com/keylesspalace/tusky/ViewThreadFragment.java
+++ b/app/src/main/java/com/keylesspalace/tusky/ViewThreadFragment.java
@@ -140,4 +140,8 @@ public class ViewThreadFragment extends SFragment implements StatusActionListene
     public void onViewThread(int position) {
         super.viewThread(adapter.getItem(position));
     }
+
+    public void onViewTag(String tag) {
+        super.viewTag(tag);
+    }
 }
diff --git a/app/src/main/res/layout/activity_view_tag.xml b/app/src/main/res/layout/activity_view_tag.xml
new file mode 100644
index 00000000..9b24139d
--- /dev/null
+++ b/app/src/main/res/layout/activity_view_tag.xml
@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="utf-8"?>
+<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:id="@+id/activity_view_thread"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:context="com.keylesspalace.tusky.ViewTagActivity">
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:orientation="vertical">
+
+        <android.support.v7.widget.Toolbar
+            android:id="@+id/toolbar"
+            android:layout_width="match_parent"
+            android:layout_height="?attr/actionBarSize"
+            android:background="?attr/colorPrimary"
+            android:elevation="4dp"
+            android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
+            app:popupTheme="@style/ThemeOverlay.AppCompat.Light" />
+
+        <FrameLayout
+            android:id="@+id/fragment_container"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent" />
+
+    </LinearLayout>
+
+    <FrameLayout
+        android:id="@+id/overlay_fragment_container"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent">
+
+    </FrameLayout>
+
+</RelativeLayout>
\ 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 62e1aa81..eb471228 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -5,6 +5,8 @@
     <string name="oauth_redirect_host">oauth2redirect</string>
     <string name="preferences_file_key">com.keylesspalace.tusky.PREFERENCES</string>
 
+    <string name="content_uri_format_tag">content://com.keylesspalace.tusky.viewtagactivity/%s</string>
+
     <string name="endpoint_status">/api/v1/statuses</string>
     <string name="endpoint_media">/api/v1/media</string>
     <string name="endpoint_timelines_home">/api/v1/timelines/home</string>
@@ -53,6 +55,7 @@
     <string name="title_notifications">Notifications</string>
     <string name="title_public">Public</string>
     <string name="title_thread">Thread</string>
+    <string name="title_tag">#%s</string>
 
     <string name="status_username_format">\@%s</string>
     <string name="status_boosted_format">%s boosted</string>