From 370b1e52aaf15d799af17e2d554a819072a7b954 Mon Sep 17 00:00:00 2001
From: Vavassor
Date: Sat, 7 Jan 2017 17:24:02 -0500
Subject: [PATCH] added a basic compose screen, and the 3 main timelines in a
tabbed layout
---
app/build.gradle | 2 +
app/src/main/AndroidManifest.xml | 3 +
.../keylesspalace/tusky/ComposeActivity.java | 137 ++++++++
.../keylesspalace/tusky/LoginActivity.java | 5 +-
.../com/keylesspalace/tusky/MainActivity.java | 229 ++-----------
.../com/keylesspalace/tusky/Notification.java | 43 +++
.../tusky/NotificationsAdapter.java | 99 ++++++
.../tusky/NotificationsFragment.java | 157 +++++++++
.../java/com/keylesspalace/tusky/Status.java | 134 +++++++-
.../tusky/StatusActionListener.java | 9 +
.../keylesspalace/tusky/TimelineAdapter.java | 87 ++++-
.../keylesspalace/tusky/TimelineFragment.java | 315 ++++++++++++++++++
.../tusky/TimelinePagerAdapter.java | 45 +++
app/src/main/res/drawable/ic_compose.xml | 11 +
app/src/main/res/drawable/ic_extra.xml | 15 +
.../main/res/drawable/ic_favourite_off.xml | 7 +
app/src/main/res/drawable/ic_favourite_on.xml | 7 +
.../main/res/drawable/ic_reblog_disabled.xml | 11 +
app/src/main/res/drawable/ic_reblog_off.xml | 11 +
app/src/main/res/drawable/ic_reblog_on.xml | 11 +
app/src/main/res/drawable/ic_reply.xml | 7 +
app/src/main/res/layout/activity_compose.xml | 69 ++++
app/src/main/res/layout/activity_main.xml | 30 +-
app/src/main/res/layout/fragment_timeline.xml | 13 +
app/src/main/res/layout/item_notification.xml | 11 +
app/src/main/res/layout/item_status.xml | 59 ++++
app/src/main/res/menu/main_toolbar.xml | 8 +-
app/src/main/res/menu/status_more.xml | 8 +
.../main/res/menu/status_more_for_user.xml | 6 +
app/src/main/res/values/strings.xml | 52 ++-
30 files changed, 1367 insertions(+), 234 deletions(-)
create mode 100644 app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java
create mode 100644 app/src/main/java/com/keylesspalace/tusky/Notification.java
create mode 100644 app/src/main/java/com/keylesspalace/tusky/NotificationsAdapter.java
create mode 100644 app/src/main/java/com/keylesspalace/tusky/NotificationsFragment.java
create mode 100644 app/src/main/java/com/keylesspalace/tusky/StatusActionListener.java
create mode 100644 app/src/main/java/com/keylesspalace/tusky/TimelineFragment.java
create mode 100644 app/src/main/java/com/keylesspalace/tusky/TimelinePagerAdapter.java
create mode 100644 app/src/main/res/drawable/ic_compose.xml
create mode 100644 app/src/main/res/drawable/ic_extra.xml
create mode 100644 app/src/main/res/drawable/ic_favourite_off.xml
create mode 100644 app/src/main/res/drawable/ic_favourite_on.xml
create mode 100644 app/src/main/res/drawable/ic_reblog_disabled.xml
create mode 100644 app/src/main/res/drawable/ic_reblog_off.xml
create mode 100644 app/src/main/res/drawable/ic_reblog_on.xml
create mode 100644 app/src/main/res/drawable/ic_reply.xml
create mode 100644 app/src/main/res/layout/activity_compose.xml
create mode 100644 app/src/main/res/layout/fragment_timeline.xml
create mode 100644 app/src/main/res/layout/item_notification.xml
create mode 100644 app/src/main/res/menu/status_more.xml
create mode 100644 app/src/main/res/menu/status_more_for_user.xml
diff --git a/app/build.gradle b/app/build.gradle
index 347693658..3d0cdce28 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -10,6 +10,7 @@ android {
versionCode 1
versionName "1.0"
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
+ vectorDrawables.useSupportLibrary true
}
buildTypes {
release {
@@ -28,4 +29,5 @@ dependencies {
compile 'com.android.support:recyclerview-v7:25.1.0'
compile 'com.android.volley:volley:1.0.0'
testCompile 'junit:junit:4.12'
+ compile 'com.android.support:design:25.1.0'
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d8bfc20aa..272b993ce 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -27,6 +27,9 @@
+
\ No newline at end of file
diff --git a/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java b/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java
new file mode 100644
index 000000000..8717305cc
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java
@@ -0,0 +1,137 @@
+package com.keylesspalace.tusky;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.os.Bundle;
+import android.support.v7.app.AppCompatActivity;
+import android.text.Editable;
+import android.text.TextWatcher;
+import android.view.View;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.RadioGroup;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import com.android.volley.AuthFailureError;
+import com.android.volley.Request;
+import com.android.volley.Response;
+import com.android.volley.VolleyError;
+import com.android.volley.toolbox.JsonObjectRequest;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public class ComposeActivity extends AppCompatActivity {
+ private static int STATUS_CHARACTER_LIMIT = 500;
+
+ private String domain;
+ private String accessToken;
+ private EditText textEditor;
+
+ private void onSendSuccess() {
+ Toast.makeText(this, "Toot!", Toast.LENGTH_SHORT).show();
+ finish();
+ }
+
+ private void onSendFailure(Exception exception) {
+ textEditor.setError(getString(R.string.error_sending_status));
+ }
+
+ private void sendStatus(String content, String visibility) {
+ String endpoint = getString(R.string.endpoint_status);
+ String url = "https://" + domain + endpoint;
+ JSONObject parameters = new JSONObject();
+ try {
+ parameters.put("status", content);
+ parameters.put("visibility", visibility);
+ } catch (JSONException e) {
+ onSendFailure(e);
+ return;
+ }
+ JsonObjectRequest request = new JsonObjectRequest(Request.Method.POST, url, parameters,
+ new Response.Listener() {
+ @Override
+ public void onResponse(JSONObject response) {
+ onSendSuccess();
+ }
+ }, new Response.ErrorListener() {
+ @Override
+ public void onErrorResponse(VolleyError error) {
+ onSendFailure(error);
+ }
+ }) {
+ @Override
+ public Map getHeaders() throws AuthFailureError {
+ Map headers = new HashMap<>();
+ headers.put("Authorization", "Bearer " + accessToken);
+ return headers;
+ }
+ };
+ VolleySingleton.getInstance(this).addToRequestQueue(request);
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_compose);
+
+ SharedPreferences preferences = getSharedPreferences(
+ getString(R.string.preferences_file_key), Context.MODE_PRIVATE);
+ domain = preferences.getString("domain", null);
+ accessToken = preferences.getString("accessToken", null);
+ assert(domain != null);
+ assert(accessToken != null);
+
+ textEditor = (EditText) findViewById(R.id.field_status);
+ final TextView charactersLeft = (TextView) findViewById(R.id.characters_left);
+ TextWatcher textEditorWatcher = new TextWatcher() {
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ int left = STATUS_CHARACTER_LIMIT - s.length();
+ charactersLeft.setText(Integer.toString(left));
+ }
+
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
+
+ @Override
+ public void afterTextChanged(Editable s) {}
+ };
+ textEditor.addTextChangedListener(textEditorWatcher);
+
+ final RadioGroup radio = (RadioGroup) findViewById(R.id.radio_visibility);
+ final Button sendButton = (Button) findViewById(R.id.button_send);
+ sendButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ Editable editable = textEditor.getText();
+ if (editable.length() <= STATUS_CHARACTER_LIMIT) {
+ int id = radio.getCheckedRadioButtonId();
+ String visibility;
+ switch (id) {
+ default:
+ case R.id.radio_public: {
+ visibility = "public";
+ break;
+ }
+ case R.id.radio_unlisted: {
+ visibility = "unlisted";
+ break;
+ }
+ case R.id.radio_private: {
+ visibility = "private";
+ break;
+ }
+ }
+ sendStatus(editable.toString(), visibility);
+ } else {
+ textEditor.setError(getString(R.string.error_compose_character_limit));
+ }
+ }
+ });
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/LoginActivity.java b/app/src/main/java/com/keylesspalace/tusky/LoginActivity.java
index 24a15975e..8a0279089 100644
--- a/app/src/main/java/com/keylesspalace/tusky/LoginActivity.java
+++ b/app/src/main/java/com/keylesspalace/tusky/LoginActivity.java
@@ -26,6 +26,8 @@ import java.util.HashMap;
import java.util.Map;
public class LoginActivity extends AppCompatActivity {
+ private static String OAUTH_SCOPES = "read write follow";
+
private SharedPreferences preferences;
private String domain;
private String clientId;
@@ -71,6 +73,7 @@ public class LoginActivity extends AppCompatActivity {
parameters.put("client_id", clientId);
parameters.put("redirect_uri", redirectUri);
parameters.put("response_type", "code");
+ parameters.put("scope", OAUTH_SCOPES);
String queryParameters;
try {
queryParameters = toQueryString(parameters);
@@ -107,7 +110,7 @@ public class LoginActivity extends AppCompatActivity {
try {
parameters.put("client_name", getString(R.string.app_name));
parameters.put("redirect_uris", getOauthRedirectUri());
- parameters.put("scopes", "read write follow");
+ parameters.put("scopes", OAUTH_SCOPES);
} catch (JSONException e) {
//TODO: error text????
return;
diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.java b/app/src/main/java/com/keylesspalace/tusky/MainActivity.java
index c43112d16..16e9e8fd6 100644
--- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.java
+++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.java
@@ -3,228 +3,41 @@ package com.keylesspalace.tusky;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
-import android.graphics.drawable.Drawable;
-import android.os.Build;
-import android.support.v4.content.ContextCompat;
-import android.support.v4.widget.SwipeRefreshLayout;
+import android.support.design.widget.TabLayout;
+import android.support.v4.view.ViewPager;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
-import android.support.v7.widget.DividerItemDecoration;
-import android.support.v7.widget.LinearLayoutManager;
-import android.support.v7.widget.RecyclerView;
import android.support.v7.widget.Toolbar;
-import android.text.Html;
-import android.text.Spanned;
import android.view.Menu;
import android.view.MenuItem;
-import android.widget.Toast;
-
-import com.android.volley.AuthFailureError;
-import com.android.volley.Response;
-import com.android.volley.VolleyError;
-import com.android.volley.toolbox.JsonArrayRequest;
-
-import org.json.JSONArray;
-import org.json.JSONException;
-import org.json.JSONObject;
-
-import java.text.ParseException;
-import java.text.SimpleDateFormat;
-import java.util.ArrayList;
-import java.util.Date;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-public class MainActivity extends AppCompatActivity implements
- SwipeRefreshLayout.OnRefreshListener {
-
- private String domain = null;
- private String accessToken = null;
- private SwipeRefreshLayout swipeRefreshLayout;
- private RecyclerView recyclerView;
- private TimelineAdapter adapter;
- private LinearLayoutManager layoutManager;
- private EndlessOnScrollListener scrollListener;
+public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
+
Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
setSupportActionBar(toolbar);
- SharedPreferences preferences = getSharedPreferences(
- getString(R.string.preferences_file_key), Context.MODE_PRIVATE);
- domain = preferences.getString("domain", null);
- accessToken = preferences.getString("accessToken", null);
- assert(domain != null);
- assert(accessToken != null);
-
- // Setup the SwipeRefreshLayout.
- swipeRefreshLayout = (SwipeRefreshLayout) findViewById(R.id.swipe_refresh_layout);
- swipeRefreshLayout.setOnRefreshListener(this);
- // Setup the RecyclerView.
- recyclerView = (RecyclerView) findViewById(R.id.recycler_view);
- recyclerView.setHasFixedSize(true);
- layoutManager = new LinearLayoutManager(this);
- recyclerView.setLayoutManager(layoutManager);
- DividerItemDecoration divider = new DividerItemDecoration(
- this, layoutManager.getOrientation());
- Drawable drawable = ContextCompat.getDrawable(this, R.drawable.status_divider);
- divider.setDrawable(drawable);
- recyclerView.addItemDecoration(divider);
- scrollListener = new EndlessOnScrollListener(layoutManager) {
- @Override
- public void onLoadMore(int page, int totalItemsCount, RecyclerView view) {
- TimelineAdapter adapter = (TimelineAdapter) view.getAdapter();
- String fromId = adapter.getItem(adapter.getItemCount() - 1).getId();
- sendFetchTimelineRequest(fromId);
- }
+ TimelinePagerAdapter adapter = new TimelinePagerAdapter(getSupportFragmentManager());
+ String[] pageTitles = {
+ getString(R.string.title_home),
+ getString(R.string.title_notifications),
+ getString(R.string.title_public)
};
- recyclerView.addOnScrollListener(scrollListener);
- adapter = new TimelineAdapter();
- recyclerView.setAdapter(adapter);
-
- sendFetchTimelineRequest();
+ adapter.setPageTitles(pageTitles);
+ ViewPager viewPager = (ViewPager) findViewById(R.id.pager);
+ viewPager.setAdapter(adapter);
+ TabLayout tabLayout = (TabLayout) findViewById(R.id.tab_layout);
+ tabLayout.setupWithViewPager(viewPager);
}
- private Date parseDate(String dateTime) {
- Date date;
- String s = dateTime.replace("Z", "+00:00");
- SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ");
- try {
- date = format.parse(s);
- } catch (ParseException e) {
- e.printStackTrace();
- return null;
- }
- return date;
+ private void compose() {
+ Intent intent = new Intent(this, ComposeActivity.class);
+ startActivity(intent);
}
- private CharSequence trimTrailingWhitespace(CharSequence s) {
- int i = s.length();
- do {
- i--;
- } while (i >= 0 && Character.isWhitespace(s.charAt(i)));
- return s.subSequence(0, i + 1);
- }
-
- private Spanned compatFromHtml(String html) {
- Spanned result;
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
- result = Html.fromHtml(html, Html.FROM_HTML_MODE_LEGACY);
- } else {
- result = Html.fromHtml(html);
- }
- /* Html.fromHtml returns trailing whitespace if the html ends in a
tag, which
- * all status contents do, so it should be trimmed. */
- return (Spanned) trimTrailingWhitespace(result);
- }
-
- private Status parseStatus(JSONObject object, boolean isReblog) throws JSONException {
- String id = object.getString("id");
- String content = object.getString("content");
- Date createdAt = parseDate(object.getString("created_at"));
-
- JSONObject account = object.getJSONObject("account");
- String displayName = account.getString("display_name");
- String username = account.getString("acct");
- String avatar = account.getString("avatar");
-
- Status reblog = null;
- /* This case shouldn't be hit after the first recursion at all. But if this method is
- * passed unusual data this check will prevent extra recursion */
- if (!isReblog) {
- JSONObject reblogObject = object.optJSONObject("reblog");
- if (reblogObject != null) {
- reblog = parseStatus(reblogObject, true);
- }
- }
-
- Status status;
- if (reblog != null) {
- status = reblog;
- status.setRebloggedByUsername(username);
- } else {
- Spanned contentPlus = compatFromHtml(content);
- status = new Status(id, displayName, username, contentPlus, avatar, createdAt);
- }
- return status;
- }
-
- private List parseStatuses(JSONArray array) throws JSONException {
- List statuses = new ArrayList<>();
- for (int i = 0; i < array.length(); i++) {
- JSONObject object = array.getJSONObject(i);
- statuses.add(parseStatus(object, false));
- }
- return statuses;
- }
-
- private void sendFetchTimelineRequest(final String fromId) {
- String endpoint = getString(R.string.endpoint_timelines_home);
- String url = "https://" + domain + endpoint;
- JsonArrayRequest request = new JsonArrayRequest(url,
- new Response.Listener() {
- @Override
- public void onResponse(JSONArray response) {
- List statuses = null;
- try {
- statuses = parseStatuses(response);
- } catch (JSONException e) {
- onFetchTimelineFailure(e);
- }
- if (statuses != null) {
- onFetchTimelineSuccess(statuses, fromId != null);
- }
- }
- }, new Response.ErrorListener() {
- @Override
- public void onErrorResponse(VolleyError error) {
- onFetchTimelineFailure(error);
- }
- }) {
- @Override
- public Map getHeaders() throws AuthFailureError {
- Map headers = new HashMap<>();
- headers.put("Authorization", "Bearer " + accessToken);
- return headers;
- }
-
- @Override
- protected Map getParams() throws AuthFailureError {
- Map parameters = new HashMap<>();
- parameters.put("max_id", fromId);
- return parameters;
- }
- };
- VolleySingleton.getInstance(this).addToRequestQueue(request);
- }
-
- private void sendFetchTimelineRequest() {
- sendFetchTimelineRequest(null);
- }
-
- public void onFetchTimelineSuccess(List statuses, boolean added) {
- if (added) {
- adapter.addItems(statuses);
- } else {
- adapter.update(statuses);
- }
- swipeRefreshLayout.setRefreshing(false);
- }
-
- public void onFetchTimelineFailure(Exception exception) {
- Toast.makeText(this, R.string.error_fetching_timeline, Toast.LENGTH_SHORT).show();
- swipeRefreshLayout.setRefreshing(false);
- }
-
- public void onRefresh() {
- sendFetchTimelineRequest();
- }
-
-
private void logOut() {
SharedPreferences preferences = getSharedPreferences(
getString(R.string.preferences_file_key), Context.MODE_PRIVATE);
@@ -246,13 +59,15 @@ public class MainActivity extends AppCompatActivity implements
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
+ case R.id.action_compose: {
+ compose();
+ return true;
+ }
case R.id.action_logout: {
logOut();
return true;
}
- default: {
- return super.onOptionsItemSelected(item);
- }
}
+ return super.onOptionsItemSelected(item);
}
}
diff --git a/app/src/main/java/com/keylesspalace/tusky/Notification.java b/app/src/main/java/com/keylesspalace/tusky/Notification.java
new file mode 100644
index 000000000..d8ab1dcdd
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/Notification.java
@@ -0,0 +1,43 @@
+package com.keylesspalace.tusky;
+
+import android.support.annotation.Nullable;
+
+public class Notification {
+ public enum Type {
+ MENTION,
+ REBLOG,
+ FAVOURITE,
+ FOLLOW,
+ }
+ private Type type;
+ private String id;
+ private String displayName;
+ /** Which of the user's statuses has been mentioned, reblogged, or favourited. */
+ private Status status;
+
+ public Notification(Type type, String id, String displayName) {
+ this.type = type;
+ this.id = id;
+ this.displayName = displayName;
+ }
+
+ public Type getType() {
+ return type;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public String getDisplayName() {
+ return displayName;
+ }
+
+ public @Nullable Status getStatus() {
+ return status;
+ }
+
+ public void setStatus(Status status) {
+ this.status = status;
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/NotificationsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/NotificationsAdapter.java
new file mode 100644
index 000000000..066fea26d
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/NotificationsAdapter.java
@@ -0,0 +1,99 @@
+package com.keylesspalace.tusky;
+
+import android.content.Context;
+import android.support.v7.widget.RecyclerView;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class NotificationsAdapter extends RecyclerView.Adapter {
+ private List notifications = new ArrayList<>();
+
+ @Override
+ public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
+ View view = LayoutInflater.from(parent.getContext())
+ .inflate(R.layout.item_notification, parent, false);
+ return new ViewHolder(view);
+ }
+
+ @Override
+ public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) {
+ ViewHolder holder = (ViewHolder) viewHolder;
+ Notification notification = notifications.get(position);
+ holder.setMessage(notification.getType(), notification.getDisplayName());
+ }
+
+ @Override
+ public int getItemCount() {
+ return notifications.size();
+ }
+
+ public Notification getItem(int position) {
+ return notifications.get(position);
+ }
+
+ public int update(List new_notifications) {
+ int scrollToPosition;
+ if (notifications == null || notifications.isEmpty()) {
+ notifications = new_notifications;
+ scrollToPosition = 0;
+ } else {
+ int index = new_notifications.indexOf(notifications.get(0));
+ if (index == -1) {
+ notifications.addAll(0, new_notifications);
+ scrollToPosition = 0;
+ } else {
+ notifications.addAll(0, new_notifications.subList(0, index));
+ scrollToPosition = index;
+ }
+ }
+ notifyDataSetChanged();
+ return scrollToPosition;
+ }
+
+ public void addItems(List new_notifications) {
+ int end = notifications.size();
+ notifications.addAll(new_notifications);
+ notifyItemRangeInserted(end, new_notifications.size());
+ }
+
+ public static class ViewHolder extends RecyclerView.ViewHolder {
+ private TextView message;
+
+ public ViewHolder(View itemView) {
+ super(itemView);
+ message = (TextView) itemView.findViewById(R.id.notification_text);
+ }
+
+ public void setMessage(Notification.Type type, String displayName) {
+ Context context = message.getContext();
+ String wholeMessage = "";
+ switch (type) {
+ case MENTION: {
+ wholeMessage = displayName + " mentioned you";
+ break;
+ }
+ case REBLOG: {
+ String format = context.getString(R.string.notification_reblog_format);
+ wholeMessage = String.format(format, displayName);
+ break;
+ }
+ case FAVOURITE: {
+ String format = context.getString(R.string.notification_favourite_format);
+ wholeMessage = String.format(format, displayName);
+ break;
+ }
+ case FOLLOW: {
+ String format = context.getString(R.string.notification_follow_format);
+ wholeMessage = String.format(format, displayName);
+ break;
+ }
+ }
+ message.setText(wholeMessage);
+ }
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/NotificationsFragment.java
new file mode 100644
index 000000000..c0e9af636
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/NotificationsFragment.java
@@ -0,0 +1,157 @@
+package com.keylesspalace.tusky;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.graphics.drawable.Drawable;
+import android.os.Bundle;
+import android.support.annotation.Nullable;
+import android.support.v4.app.Fragment;
+import android.support.v4.content.ContextCompat;
+import android.support.v4.widget.SwipeRefreshLayout;
+import android.support.v7.widget.DividerItemDecoration;
+import android.support.v7.widget.LinearLayoutManager;
+import android.support.v7.widget.RecyclerView;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Toast;
+
+import com.android.volley.AuthFailureError;
+import com.android.volley.Response;
+import com.android.volley.VolleyError;
+import com.android.volley.toolbox.JsonArrayRequest;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class NotificationsFragment extends Fragment implements
+ SwipeRefreshLayout.OnRefreshListener {
+ private String domain = null;
+ private String accessToken = null;
+ private SwipeRefreshLayout swipeRefreshLayout;
+ private NotificationsAdapter adapter;
+
+ public static NotificationsFragment newInstance() {
+ NotificationsFragment fragment = new NotificationsFragment();
+ Bundle arguments = new Bundle();
+ fragment.setArguments(arguments);
+ return fragment;
+ }
+
+ @Nullable
+ @Override
+ public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container,
+ @Nullable Bundle savedInstanceState) {
+ View rootView = inflater.inflate(R.layout.fragment_timeline, container, false);
+
+ Context context = getContext();
+ SharedPreferences preferences = context.getSharedPreferences(
+ getString(R.string.preferences_file_key), Context.MODE_PRIVATE);
+ domain = preferences.getString("domain", null);
+ accessToken = preferences.getString("accessToken", null);
+ assert(domain != null);
+ assert(accessToken != null);
+
+ // Setup the SwipeRefreshLayout.
+ swipeRefreshLayout = (SwipeRefreshLayout) rootView.findViewById(R.id.swipe_refresh_layout);
+ swipeRefreshLayout.setOnRefreshListener(this);
+ // Setup the RecyclerView.
+ RecyclerView recyclerView = (RecyclerView) rootView.findViewById(R.id.recycler_view);
+ recyclerView.setHasFixedSize(true);
+ LinearLayoutManager layoutManager = new LinearLayoutManager(context);
+ recyclerView.setLayoutManager(layoutManager);
+ DividerItemDecoration divider = new DividerItemDecoration(
+ context, layoutManager.getOrientation());
+ Drawable drawable = ContextCompat.getDrawable(context, R.drawable.status_divider);
+ divider.setDrawable(drawable);
+ recyclerView.addItemDecoration(divider);
+ EndlessOnScrollListener scrollListener = new EndlessOnScrollListener(layoutManager) {
+ @Override
+ public void onLoadMore(int page, int totalItemsCount, RecyclerView view) {
+ NotificationsAdapter adapter = (NotificationsAdapter) view.getAdapter();
+ String fromId = adapter.getItem(adapter.getItemCount() - 1).getId();
+ sendFetchNotificationsRequest(fromId);
+ }
+ };
+ recyclerView.addOnScrollListener(scrollListener);
+ adapter = new NotificationsAdapter();
+ recyclerView.setAdapter(adapter);
+
+ sendFetchNotificationsRequest();
+
+ return rootView;
+ }
+
+ private void sendFetchNotificationsRequest(final String fromId) {
+ String endpoint = getString(R.string.endpoint_notifications);
+ String url = "https://" + domain + endpoint;
+ if (fromId != null) {
+ url += "?max_id=" + fromId;
+ }
+ JsonArrayRequest request = new JsonArrayRequest(url,
+ new Response.Listener() {
+ @Override
+ public void onResponse(JSONArray response) {
+ List notifications = new ArrayList<>();
+ try {
+ for (int i = 0; i < response.length(); i++) {
+ JSONObject object = response.getJSONObject(i);
+ String id = object.getString("id");
+ Notification.Type type = Notification.Type.valueOf(
+ object.getString("type").toUpperCase());
+ JSONObject account = object.getJSONObject("account");
+ String displayName = account.getString("display_name");
+ Notification notification = new Notification(type, id, displayName);
+ notifications.add(notification);
+ }
+ onFetchNotificationsSuccess(notifications, fromId != null);
+ } catch (JSONException e) {
+ onFetchNotificationsFailure(e);
+ }
+ }
+ }, new Response.ErrorListener() {
+ @Override
+ public void onErrorResponse(VolleyError error) {
+ onFetchNotificationsFailure(error);
+ }
+ }) {
+ @Override
+ public Map getHeaders() throws AuthFailureError {
+ Map headers = new HashMap<>();
+ headers.put("Authorization", "Bearer " + accessToken);
+ return headers;
+ }
+ };
+ VolleySingleton.getInstance(getContext()).addToRequestQueue(request);
+ }
+
+ private void sendFetchNotificationsRequest() {
+ sendFetchNotificationsRequest(null);
+ }
+
+ private void onFetchNotificationsSuccess(List notifications, boolean added) {
+ if (added) {
+ adapter.addItems(notifications);
+ } else {
+ adapter.update(notifications);
+ }
+ swipeRefreshLayout.setRefreshing(false);
+ }
+
+ private void onFetchNotificationsFailure(Exception exception) {
+ Toast.makeText(getContext(), R.string.error_fetching_notifications, Toast.LENGTH_SHORT)
+ .show();
+ swipeRefreshLayout.setRefreshing(false);
+ }
+
+ @Override
+ public void onRefresh() {
+ sendFetchNotificationsRequest();
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/Status.java b/app/src/main/java/com/keylesspalace/tusky/Status.java
index 4eef6ea30..d69855a3f 100644
--- a/app/src/main/java/com/keylesspalace/tusky/Status.java
+++ b/app/src/main/java/com/keylesspalace/tusky/Status.java
@@ -1,11 +1,28 @@
package com.keylesspalace.tusky;
+import android.os.Build;
+import android.text.Html;
import android.text.Spanned;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
import java.util.Date;
+import java.util.List;
public class Status {
+ enum Visibility {
+ PUBLIC,
+ UNLISTED,
+ PRIVATE,
+ }
+
private String id;
+ private String accountId;
private String displayName;
/** the username with the remote domain appended, like @domain.name, if it's a remote account */
private String username;
@@ -16,21 +33,35 @@ public class Status {
private String rebloggedByUsername;
/** when the status was initially created */
private Date createdAt;
+ /** whether the authenticated user has reblogged this status */
+ private boolean reblogged;
+ /** whether the authenticated user has favourited this status */
+ private boolean favourited;
+ private Visibility visibility;
- public Status(String id, String displayName, String username, Spanned content, String avatar,
- Date createdAt) {
+ public Status(String id, String accountId, String displayName, String username, Spanned content,
+ String avatar, Date createdAt, boolean reblogged, boolean favourited,
+ String visibility) {
this.id = id;
+ this.accountId = accountId;
this.displayName = displayName;
this.username = username;
this.content = content;
this.avatar = avatar;
this.createdAt = createdAt;
+ this.reblogged = reblogged;
+ this.favourited = favourited;
+ this.visibility = Visibility.valueOf(visibility.toUpperCase());
}
public String getId() {
return id;
}
+ public String getAccountId() {
+ return accountId;
+ }
+
public String getDisplayName() {
return displayName;
}
@@ -55,10 +86,30 @@ public class Status {
return rebloggedByUsername;
}
+ public boolean getReblogged() {
+ return reblogged;
+ }
+
+ public boolean getFavourited() {
+ return favourited;
+ }
+
+ public Visibility getVisibility() {
+ return visibility;
+ }
+
public void setRebloggedByUsername(String name) {
rebloggedByUsername = name;
}
+ public void setReblogged(boolean reblogged) {
+ this.reblogged = reblogged;
+ }
+
+ public void setFavourited(boolean favourited) {
+ this.favourited = favourited;
+ }
+
@Override
public int hashCode() {
return id.hashCode();
@@ -74,4 +125,83 @@ public class Status {
Status status = (Status) other;
return status.id.equals(this.id);
}
+
+ private static Date parseDate(String dateTime) {
+ Date date;
+ String s = dateTime.replace("Z", "+00:00");
+ SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ");
+ try {
+ date = format.parse(s);
+ } catch (ParseException e) {
+ e.printStackTrace();
+ return null;
+ }
+ return date;
+ }
+
+ private static CharSequence trimTrailingWhitespace(CharSequence s) {
+ int i = s.length();
+ do {
+ i--;
+ } while (i >= 0 && Character.isWhitespace(s.charAt(i)));
+ return s.subSequence(0, i + 1);
+ }
+
+ private static Spanned compatFromHtml(String html) {
+ Spanned result;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ result = Html.fromHtml(html, Html.FROM_HTML_MODE_LEGACY);
+ } else {
+ result = Html.fromHtml(html);
+ }
+ /* Html.fromHtml returns trailing whitespace if the html ends in a tag, which
+ * all status contents do, so it should be trimmed. */
+ return (Spanned) trimTrailingWhitespace(result);
+ }
+
+ public static Status parse(JSONObject object, boolean isReblog) throws JSONException {
+ String id = object.getString("id");
+ String content = object.getString("content");
+ Date createdAt = parseDate(object.getString("created_at"));
+ boolean reblogged = object.getBoolean("reblogged");
+ boolean favourited = object.getBoolean("favourited");
+ String visibility = object.getString("visibility");
+
+ JSONObject account = object.getJSONObject("account");
+ String accountId = account.getString("id");
+ String displayName = account.getString("display_name");
+ String username = account.getString("acct");
+ String avatar = account.getString("avatar");
+
+ Status reblog = null;
+ /* This case shouldn't be hit after the first recursion at all. But if this method is
+ * passed unusual data this check will prevent extra recursion */
+ if (!isReblog) {
+ JSONObject reblogObject = object.optJSONObject("reblog");
+ if (reblogObject != null) {
+ reblog = parse(reblogObject, true);
+ }
+ }
+
+ Status status;
+ if (reblog != null) {
+ status = reblog;
+ status.setRebloggedByUsername(username);
+ } else {
+ Spanned contentPlus = compatFromHtml(content);
+ status = new Status(
+ id, accountId, displayName, username, contentPlus, avatar, createdAt,
+ reblogged, favourited, visibility);
+ }
+ return status;
+ }
+
+ public static List parse(JSONArray array) throws JSONException {
+ List statuses = new ArrayList<>();
+ for (int i = 0; i < array.length(); i++) {
+ JSONObject object = array.getJSONObject(i);
+ statuses.add(parse(object, false));
+ }
+ return statuses;
+ }
}
diff --git a/app/src/main/java/com/keylesspalace/tusky/StatusActionListener.java b/app/src/main/java/com/keylesspalace/tusky/StatusActionListener.java
new file mode 100644
index 000000000..fb261bb13
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/StatusActionListener.java
@@ -0,0 +1,9 @@
+package com.keylesspalace.tusky;
+
+import android.view.View;
+
+public interface StatusActionListener {
+ void onReblog(final boolean reblog, final int position);
+ void onFavourite(final boolean favourite, final int position);
+ void onMore(View view, final int position);
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/TimelineAdapter.java b/app/src/main/java/com/keylesspalace/tusky/TimelineAdapter.java
index 5d4506ee5..41a831699 100644
--- a/app/src/main/java/com/keylesspalace/tusky/TimelineAdapter.java
+++ b/app/src/main/java/com/keylesspalace/tusky/TimelineAdapter.java
@@ -1,13 +1,13 @@
package com.keylesspalace.tusky;
import android.content.Context;
-import android.graphics.Bitmap;
import android.support.annotation.Nullable;
import android.support.v7.widget.RecyclerView;
import android.text.Spanned;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
+import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.TextView;
@@ -21,14 +21,12 @@ import java.util.List;
public class TimelineAdapter extends RecyclerView.Adapter {
private List statuses = new ArrayList<>();
- /*
- TootActionListener listener;
+ StatusActionListener listener;
- public TimelineAdapter(TootActionListener listener) {
+ public TimelineAdapter(StatusActionListener listener) {
super();
this.listener = listener;
}
- */
@Override
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup viewGroup, int viewType) {
@@ -47,13 +45,18 @@ public class TimelineAdapter extends RecyclerView.Adapter {
holder.setContent(status.getContent());
holder.setAvatar(status.getAvatar());
holder.setContent(status.getContent());
+ holder.setReblogged(status.getReblogged());
+ holder.setFavourited(status.getFavourited());
String rebloggedByUsername = status.getRebloggedByUsername();
if (rebloggedByUsername == null) {
- holder.hideReblogged();
+ holder.hideRebloggedByUsername();
} else {
holder.setRebloggedByUsername(rebloggedByUsername);
}
- // holder.initButtons(mListener, position);
+ holder.setupButtons(listener, position);
+ if (status.getVisibility() == Status.Visibility.PRIVATE) {
+ holder.disableReblogging();
+ }
}
@Override
@@ -86,6 +89,11 @@ public class TimelineAdapter extends RecyclerView.Adapter {
notifyItemRangeInserted(end, new_statuses.size());
}
+ public void removeItem(int position) {
+ statuses.remove(position);
+ notifyItemRemoved(position);
+ }
+
public Status getItem(int position) {
return statuses.get(position);
}
@@ -98,6 +106,12 @@ public class TimelineAdapter extends RecyclerView.Adapter {
private NetworkImageView avatar;
private ImageView boostedIcon;
private TextView boostedByUsername;
+ private ImageButton replyButton;
+ private ImageButton reblogButton;
+ private ImageButton favouriteButton;
+ private ImageButton moreButton;
+ private boolean favourited;
+ private boolean reblogged;
public ViewHolder(View itemView) {
super(itemView);
@@ -108,11 +122,12 @@ public class TimelineAdapter extends RecyclerView.Adapter {
avatar = (NetworkImageView) itemView.findViewById(R.id.status_avatar);
boostedIcon = (ImageView) itemView.findViewById(R.id.status_boosted_icon);
boostedByUsername = (TextView) itemView.findViewById(R.id.status_boosted);
- /*
- mReplyButton = (ImageButton) itemView.findViewById(R.id.reply);
- mRetweetButton = (ImageButton) itemView.findViewById(R.id.retweet);
- mFavoriteButton = (ImageButton) itemView.findViewById(R.id.favorite);
- */
+ replyButton = (ImageButton) itemView.findViewById(R.id.status_reply);
+ reblogButton = (ImageButton) itemView.findViewById(R.id.status_reblog);
+ favouriteButton = (ImageButton) itemView.findViewById(R.id.status_favourite);
+ moreButton = (ImageButton) itemView.findViewById(R.id.status_more);
+ reblogged = false;
+ favourited = false;
}
public void setDisplayName(String name) {
@@ -177,7 +192,7 @@ public class TimelineAdapter extends RecyclerView.Adapter {
long now = new Date().getTime();
readout = getRelativeTimeSpanString(then, now);
} else {
- readout = "?m";
+ readout = "?m"; // unknown minutes~
}
sinceCreated.setText(readout);
}
@@ -191,9 +206,53 @@ public class TimelineAdapter extends RecyclerView.Adapter {
boostedByUsername.setVisibility(View.VISIBLE);
}
- public void hideReblogged() {
+ public void hideRebloggedByUsername() {
boostedIcon.setVisibility(View.GONE);
boostedByUsername.setVisibility(View.GONE);
}
+
+ public void setReblogged(boolean reblogged) {
+ this.reblogged = reblogged;
+ if (!reblogged) {
+ reblogButton.setImageResource(R.drawable.ic_reblog_off);
+ } else {
+ reblogButton.setImageResource(R.drawable.ic_reblog_on);
+ }
+ }
+
+ public void disableReblogging() {
+ reblogButton.setEnabled(false);
+ reblogButton.setImageResource(R.drawable.ic_reblog_disabled);
+ }
+
+ public void setFavourited(boolean favourited) {
+ this.favourited = favourited;
+ if (!favourited) {
+ favouriteButton.setImageResource(R.drawable.ic_favourite_off);
+ } else {
+ favouriteButton.setImageResource(R.drawable.ic_favourite_on);
+ }
+ }
+
+ public void setupButtons(final StatusActionListener listener, final int position) {
+ reblogButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ listener.onReblog(!reblogged, position);
+ }
+ });
+ favouriteButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ listener.onFavourite(!favourited, position);
+ }
+ });
+ moreButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ listener.onMore(v, position);
+ }
+ });
+ }
}
}
diff --git a/app/src/main/java/com/keylesspalace/tusky/TimelineFragment.java b/app/src/main/java/com/keylesspalace/tusky/TimelineFragment.java
new file mode 100644
index 000000000..f687e99a0
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/TimelineFragment.java
@@ -0,0 +1,315 @@
+package com.keylesspalace.tusky;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.graphics.drawable.Drawable;
+import android.os.Bundle;
+import android.support.annotation.Nullable;
+import android.support.v4.app.Fragment;
+import android.support.v4.content.ContextCompat;
+import android.support.v4.widget.SwipeRefreshLayout;
+import android.support.v7.widget.DividerItemDecoration;
+import android.support.v7.widget.LinearLayoutManager;
+import android.support.v7.widget.PopupMenu;
+import android.support.v7.widget.RecyclerView;
+import android.view.LayoutInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Toast;
+
+import com.android.volley.AuthFailureError;
+import com.android.volley.Request;
+import com.android.volley.Response;
+import com.android.volley.VolleyError;
+import com.android.volley.toolbox.JsonArrayRequest;
+import com.android.volley.toolbox.JsonObjectRequest;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class TimelineFragment extends Fragment implements
+ SwipeRefreshLayout.OnRefreshListener, StatusActionListener {
+
+ public enum Kind {
+ HOME,
+ MENTIONS,
+ PUBLIC,
+ }
+
+ private String domain = null;
+ private String accessToken = null;
+ private String userAccountId = null;
+ private SwipeRefreshLayout swipeRefreshLayout;
+ private TimelineAdapter adapter;
+ private Kind kind;
+
+ public static TimelineFragment newInstance(Kind kind) {
+ TimelineFragment fragment = new TimelineFragment();
+ Bundle arguments = new Bundle();
+ arguments.putString("kind", kind.name());
+ fragment.setArguments(arguments);
+ return fragment;
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+
+ kind = Kind.valueOf(getArguments().getString("kind"));
+
+ View rootView = inflater.inflate(R.layout.fragment_timeline, container, false);
+
+ Context context = getContext();
+ SharedPreferences preferences = context.getSharedPreferences(
+ getString(R.string.preferences_file_key), Context.MODE_PRIVATE);
+ domain = preferences.getString("domain", null);
+ accessToken = preferences.getString("accessToken", null);
+ assert(domain != null);
+ assert(accessToken != null);
+
+ // Setup the SwipeRefreshLayout.
+ swipeRefreshLayout = (SwipeRefreshLayout) rootView.findViewById(R.id.swipe_refresh_layout);
+ swipeRefreshLayout.setOnRefreshListener(this);
+ // Setup the RecyclerView.
+ RecyclerView recyclerView = (RecyclerView) rootView.findViewById(R.id.recycler_view);
+ recyclerView.setHasFixedSize(true);
+ LinearLayoutManager layoutManager = new LinearLayoutManager(context);
+ recyclerView.setLayoutManager(layoutManager);
+ DividerItemDecoration divider = new DividerItemDecoration(
+ context, layoutManager.getOrientation());
+ Drawable drawable = ContextCompat.getDrawable(context, R.drawable.status_divider);
+ divider.setDrawable(drawable);
+ recyclerView.addItemDecoration(divider);
+ EndlessOnScrollListener scrollListener = new EndlessOnScrollListener(layoutManager) {
+ @Override
+ public void onLoadMore(int page, int totalItemsCount, RecyclerView view) {
+ TimelineAdapter adapter = (TimelineAdapter) view.getAdapter();
+ String fromId = adapter.getItem(adapter.getItemCount() - 1).getId();
+ sendFetchTimelineRequest(fromId);
+ }
+ };
+ recyclerView.addOnScrollListener(scrollListener);
+ adapter = new TimelineAdapter(this);
+ recyclerView.setAdapter(adapter);
+
+ sendUserInfoRequest();
+ sendFetchTimelineRequest();
+
+ return rootView;
+ }
+
+ private void sendUserInfoRequest() {
+ sendRequest(Request.Method.GET, getString(R.string.endpoint_verify_credentials), null,
+ new Response.Listener() {
+ @Override
+ public void onResponse(JSONObject response) {
+ try {
+ userAccountId = response.getString("id");
+ } catch (JSONException e) {
+ //TODO: Help
+ assert(false);
+ }
+ }
+ });
+ }
+
+ private void sendFetchTimelineRequest(final String fromId) {
+ String endpoint;
+ switch (kind) {
+ default:
+ case HOME: {
+ endpoint = getString(R.string.endpoint_timelines_home);
+ break;
+ }
+ case MENTIONS: {
+ endpoint = getString(R.string.endpoint_timelines_mentions);
+ break;
+ }
+ case PUBLIC: {
+ endpoint = getString(R.string.endpoint_timelines_public);
+ break;
+ }
+ }
+ String url = "https://" + domain + endpoint;
+ if (fromId != null) {
+ url += "?max_id=" + fromId;
+ }
+ JsonArrayRequest request = new JsonArrayRequest(url,
+ new Response.Listener() {
+ @Override
+ public void onResponse(JSONArray response) {
+ List statuses = null;
+ try {
+ statuses = Status.parse(response);
+ } catch (JSONException e) {
+ onFetchTimelineFailure(e);
+ }
+ if (statuses != null) {
+ onFetchTimelineSuccess(statuses, fromId != null);
+ }
+ }
+ }, new Response.ErrorListener() {
+ @Override
+ public void onErrorResponse(VolleyError error) {
+ onFetchTimelineFailure(error);
+ }
+ }) {
+ @Override
+ public Map getHeaders() throws AuthFailureError {
+ Map headers = new HashMap<>();
+ headers.put("Authorization", "Bearer " + accessToken);
+ return headers;
+ }
+ };
+ VolleySingleton.getInstance(getContext()).addToRequestQueue(request);
+ }
+
+ private void sendFetchTimelineRequest() {
+ sendFetchTimelineRequest(null);
+ }
+
+ public void onFetchTimelineSuccess(List statuses, boolean added) {
+ if (added) {
+ adapter.addItems(statuses);
+ } else {
+ adapter.update(statuses);
+ }
+ swipeRefreshLayout.setRefreshing(false);
+ }
+
+ public void onFetchTimelineFailure(Exception exception) {
+ Toast.makeText(getContext(), R.string.error_fetching_timeline, Toast.LENGTH_SHORT).show();
+ swipeRefreshLayout.setRefreshing(false);
+ }
+
+ public void onRefresh() {
+ sendFetchTimelineRequest();
+ }
+
+ private void sendRequest(
+ int method, String endpoint, JSONObject parameters,
+ @Nullable Response.Listener responseListener) {
+ if (responseListener == null) {
+ // Use a dummy listener if one wasn't specified so the request can be constructed.
+ responseListener = new Response.Listener() {
+ @Override
+ public void onResponse(JSONObject response) {}
+ };
+ }
+ String url = "https://" + domain + endpoint;
+ JsonObjectRequest request = new JsonObjectRequest(
+ method, url, parameters, responseListener,
+ new Response.ErrorListener() {
+ @Override
+ public void onErrorResponse(VolleyError error) {
+ System.err.println(error.getMessage());
+ }
+ }) {
+ @Override
+ public Map getHeaders() throws AuthFailureError {
+ Map headers = new HashMap<>();
+ headers.put("Authorization", "Bearer " + accessToken);
+ return headers;
+ }
+ };
+ VolleySingleton.getInstance(getContext()).addToRequestQueue(request);
+ }
+
+ private void postRequest(String endpoint) {
+ sendRequest(Request.Method.POST, endpoint, null, null);
+ }
+
+ public void onReblog(final boolean reblog, final int position) {
+ final Status status = adapter.getItem(position);
+ String id = status.getId();
+ String endpoint;
+ if (reblog) {
+ endpoint = String.format(getString(R.string.endpoint_reblog), id);
+ } else {
+ endpoint = String.format(getString(R.string.endpoint_unreblog), id);
+ }
+ sendRequest(Request.Method.POST, endpoint, null,
+ new Response.Listener() {
+ @Override
+ public void onResponse(JSONObject response) {
+ status.setReblogged(reblog);
+ adapter.notifyItemChanged(position);
+ }
+ });
+ }
+
+ public void onFavourite(final boolean favourite, final int position) {
+ final Status status = adapter.getItem(position);
+ String id = status.getId();
+ String endpoint;
+ if (favourite) {
+ endpoint = String.format(getString(R.string.endpoint_favourite), id);
+ } else {
+ endpoint = String.format(getString(R.string.endpoint_unfavourite), id);
+ }
+ sendRequest(Request.Method.POST, endpoint, null, new Response.Listener() {
+ @Override
+ public void onResponse(JSONObject response) {
+ status.setFavourited(favourite);
+ adapter.notifyItemChanged(position);
+ }
+ });
+ }
+
+ private void follow(String id) {
+ String endpoint = String.format(getString(R.string.endpoint_follow), id);
+ postRequest(endpoint);
+ }
+
+ private void block(String id) {
+ String endpoint = String.format(getString(R.string.endpoint_block), id);
+ postRequest(endpoint);
+ }
+
+ private void delete(String id) {
+ String endpoint = String.format(getString(R.string.endpoint_delete), id);
+ sendRequest(Request.Method.DELETE, endpoint, null, null);
+ }
+
+ public void onMore(View view, final int position) {
+ Status status = adapter.getItem(position);
+ final String id = status.getId();
+ final String accountId = status.getAccountId();
+ PopupMenu popup = new PopupMenu(getContext(), view);
+ // Give a different menu depending on whether this is the user's own toot or not.
+ if (userAccountId == null || !userAccountId.equals(accountId)) {
+ popup.inflate(R.menu.status_more);
+ } else {
+ popup.inflate(R.menu.status_more_for_user);
+ }
+ popup.setOnMenuItemClickListener(
+ new PopupMenu.OnMenuItemClickListener() {
+ @Override
+ public boolean onMenuItemClick(MenuItem item) {
+ switch (item.getItemId()) {
+ case R.id.status_follow: {
+ follow(accountId);
+ return true;
+ }
+ case R.id.status_block: {
+ block(accountId);
+ return true;
+ }
+ case R.id.status_delete: {
+ delete(id);
+ adapter.removeItem(position);
+ return true;
+ }
+ }
+ return false;
+ }
+ });
+ popup.show();
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/TimelinePagerAdapter.java b/app/src/main/java/com/keylesspalace/tusky/TimelinePagerAdapter.java
new file mode 100644
index 000000000..7bac4e046
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/TimelinePagerAdapter.java
@@ -0,0 +1,45 @@
+package com.keylesspalace.tusky;
+
+import android.support.v4.app.Fragment;
+import android.support.v4.app.FragmentManager;
+import android.support.v4.app.FragmentPagerAdapter;
+
+public class TimelinePagerAdapter extends FragmentPagerAdapter {
+ private String[] pageTitles;
+
+ public TimelinePagerAdapter(FragmentManager manager) {
+ super(manager);
+ }
+
+ public void setPageTitles(String[] titles) {
+ pageTitles = titles;
+ }
+
+ @Override
+ public Fragment getItem(int i) {
+ switch (i) {
+ case 0: {
+ return TimelineFragment.newInstance(TimelineFragment.Kind.HOME);
+ }
+ case 1: {
+ return NotificationsFragment.newInstance();
+ }
+ case 2: {
+ return TimelineFragment.newInstance(TimelineFragment.Kind.PUBLIC);
+ }
+ default: {
+ return null;
+ }
+ }
+ }
+
+ @Override
+ public int getCount() {
+ return 3;
+ }
+
+ @Override
+ public CharSequence getPageTitle(int position) {
+ return pageTitles[position];
+ }
+}
diff --git a/app/src/main/res/drawable/ic_compose.xml b/app/src/main/res/drawable/ic_compose.xml
new file mode 100644
index 000000000..c5669bb7a
--- /dev/null
+++ b/app/src/main/res/drawable/ic_compose.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_extra.xml b/app/src/main/res/drawable/ic_extra.xml
new file mode 100644
index 000000000..a87c95d53
--- /dev/null
+++ b/app/src/main/res/drawable/ic_extra.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_favourite_off.xml b/app/src/main/res/drawable/ic_favourite_off.xml
new file mode 100644
index 000000000..7a171bea2
--- /dev/null
+++ b/app/src/main/res/drawable/ic_favourite_off.xml
@@ -0,0 +1,7 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_favourite_on.xml b/app/src/main/res/drawable/ic_favourite_on.xml
new file mode 100644
index 000000000..7ebaaee58
--- /dev/null
+++ b/app/src/main/res/drawable/ic_favourite_on.xml
@@ -0,0 +1,7 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_reblog_disabled.xml b/app/src/main/res/drawable/ic_reblog_disabled.xml
new file mode 100644
index 000000000..8d1960db3
--- /dev/null
+++ b/app/src/main/res/drawable/ic_reblog_disabled.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_reblog_off.xml b/app/src/main/res/drawable/ic_reblog_off.xml
new file mode 100644
index 000000000..bae42451b
--- /dev/null
+++ b/app/src/main/res/drawable/ic_reblog_off.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_reblog_on.xml b/app/src/main/res/drawable/ic_reblog_on.xml
new file mode 100644
index 000000000..516a346be
--- /dev/null
+++ b/app/src/main/res/drawable/ic_reblog_on.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_reply.xml b/app/src/main/res/drawable/ic_reply.xml
new file mode 100644
index 000000000..03247650f
--- /dev/null
+++ b/app/src/main/res/drawable/ic_reply.xml
@@ -0,0 +1,7 @@
+
+
+
diff --git a/app/src/main/res/layout/activity_compose.xml b/app/src/main/res/layout/activity_compose.xml
new file mode 100644
index 000000000..a02f76fd4
--- /dev/null
+++ b/app/src/main/res/layout/activity_compose.xml
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 6dfc9ffec..3ff98df8e 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -19,18 +19,34 @@
android:background="?attr/colorPrimary"
android:elevation="4dp"
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
- app:popupTheme="@style/ThemeOverlay.AppCompat.Light"/>
+ app:popupTheme="@style/ThemeOverlay.AppCompat.Light" />
-
-
+ android:layout_height="wrap_content">
-
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_timeline.xml b/app/src/main/res/layout/fragment_timeline.xml
new file mode 100644
index 000000000..e19ddd748
--- /dev/null
+++ b/app/src/main/res/layout/fragment_timeline.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_notification.xml b/app/src/main/res/layout/item_notification.xml
new file mode 100644
index 000000000..f0c40d8a4
--- /dev/null
+++ b/app/src/main/res/layout/item_notification.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_status.xml b/app/src/main/res/layout/item_status.xml
index f8b43b34b..a20f4a4a3 100644
--- a/app/src/main/res/layout/item_status.xml
+++ b/app/src/main/res/layout/item_status.xml
@@ -76,4 +76,63 @@
android:layout_toEndOf="@+id/status_avatar"
android:layout_below="@+id/status_name_bar" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/menu/main_toolbar.xml b/app/src/main/res/menu/main_toolbar.xml
index 1c49f7251..dedc9859b 100644
--- a/app/src/main/res/menu/main_toolbar.xml
+++ b/app/src/main/res/menu/main_toolbar.xml
@@ -1,7 +1,13 @@