added a basic compose screen, and the 3 main timelines in a tabbed layout

This commit is contained in:
Vavassor 2017-01-07 17:24:02 -05:00
commit 370b1e52aa
30 changed files with 1367 additions and 234 deletions

View file

@ -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<JSONObject>() {
@Override
public void onResponse(JSONObject response) {
onSendSuccess();
}
}, new Response.ErrorListener() {
@Override
public void onErrorResponse(VolleyError error) {
onSendFailure(error);
}
}) {
@Override
public Map<String, String> getHeaders() throws AuthFailureError {
Map<String, String> 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));
}
}
});
}
}

View file

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

View file

@ -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 </p> 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<Status> parseStatuses(JSONArray array) throws JSONException {
List<Status> 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<JSONArray>() {
@Override
public void onResponse(JSONArray response) {
List<Status> 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<String, String> getHeaders() throws AuthFailureError {
Map<String, String> headers = new HashMap<>();
headers.put("Authorization", "Bearer " + accessToken);
return headers;
}
@Override
protected Map<String, String> getParams() throws AuthFailureError {
Map<String, String> parameters = new HashMap<>();
parameters.put("max_id", fromId);
return parameters;
}
};
VolleySingleton.getInstance(this).addToRequestQueue(request);
}
private void sendFetchTimelineRequest() {
sendFetchTimelineRequest(null);
}
public void onFetchTimelineSuccess(List<Status> 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);
}
}

View file

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

View file

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

View file

@ -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<JSONArray>() {
@Override
public void onResponse(JSONArray response) {
List<Notification> 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<String, String> getHeaders() throws AuthFailureError {
Map<String, String> headers = new HashMap<>();
headers.put("Authorization", "Bearer " + accessToken);
return headers;
}
};
VolleySingleton.getInstance(getContext()).addToRequestQueue(request);
}
private void sendFetchNotificationsRequest() {
sendFetchNotificationsRequest(null);
}
private void onFetchNotificationsSuccess(List<Notification> 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();
}
}

View file

@ -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 </p> 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<Status> parse(JSONArray array) throws JSONException {
List<Status> statuses = new ArrayList<>();
for (int i = 0; i < array.length(); i++) {
JSONObject object = array.getJSONObject(i);
statuses.add(parse(object, false));
}
return statuses;
}
}

View file

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

View file

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

View file

@ -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<JSONObject>() {
@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<JSONArray>() {
@Override
public void onResponse(JSONArray response) {
List<Status> 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<String, String> getHeaders() throws AuthFailureError {
Map<String, String> headers = new HashMap<>();
headers.put("Authorization", "Bearer " + accessToken);
return headers;
}
};
VolleySingleton.getInstance(getContext()).addToRequestQueue(request);
}
private void sendFetchTimelineRequest() {
sendFetchTimelineRequest(null);
}
public void onFetchTimelineSuccess(List<Status> 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<JSONObject> responseListener) {
if (responseListener == null) {
// Use a dummy listener if one wasn't specified so the request can be constructed.
responseListener = new Response.Listener<JSONObject>() {
@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<String, String> getHeaders() throws AuthFailureError {
Map<String, String> 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<JSONObject>() {
@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<JSONObject>() {
@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();
}
}

View file

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