Adds a toot thread viewing mode. Also, many files were missing and didn't push so the previous commits may have been very wrong?

This commit is contained in:
Vavassor 2017-01-23 00:19:30 -05:00
parent 32fecabd7f
commit b00a3cf443
31 changed files with 1274 additions and 566 deletions

View File

@ -32,6 +32,7 @@
android:name=".ComposeActivity"
android:windowSoftInputMode="stateVisible|adjustResize" />
<activity android:name=".ViewVideoActivity" />
<activity android:name=".ViewThreadActivity" />
</application>
</manifest>

View File

@ -0,0 +1,5 @@
package com.keylesspalace.tusky;
public interface AdapterItemRemover {
void removeItem(int position);
}

View File

@ -323,7 +323,7 @@ public class ComposeActivity extends AppCompatActivity {
}
private void onSendSuccess() {
Toast.makeText(this, "Toot!", Toast.LENGTH_SHORT).show();
Toast.makeText(this, getString(R.string.confirmation_send), Toast.LENGTH_SHORT).show();
finish();
}

View File

@ -0,0 +1,50 @@
/* Copyright 2017 Andrew Dawson
*
* This file is part of Tusky.
*
* Tusky is free software: you can redistribute it and/or modify it under the terms of the GNU
* General Public License as published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky. If not, see
* <http://www.gnu.org/licenses/>. */
package com.keylesspalace.tusky;
public class DateUtils {
/* This is a rough duplicate of android.text.format.DateUtils.getRelativeTimeSpanString,
* but even with the FORMAT_ABBREV_RELATIVE flag it wasn't abbreviating enough. */
public static String getRelativeTimeSpanString(long then, long now) {
final long MINUTE = 60;
final long HOUR = 60 * MINUTE;
final long DAY = 24 * HOUR;
final long YEAR = 365 * DAY;
long span = (now - then) / 1000;
String prefix = "";
if (span < 0) {
prefix = "in ";
span = -span;
}
String unit;
if (span < MINUTE) {
unit = "s";
} else if (span < HOUR) {
span /= MINUTE;
unit = "m";
} else if (span < DAY) {
span /= HOUR;
unit = "h";
} else if (span < YEAR) {
span /= DAY;
unit = "d";
} else {
span /= YEAR;
unit = "y";
}
return prefix + span + unit;
}
}

View File

@ -0,0 +1,55 @@
/* Copyright 2017 Andrew Dawson
*
* This file is part of Tusky.
*
* Tusky is free software: you can redistribute it and/or modify it under the terms of the GNU
* General Public License as published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky. If not, see
* <http://www.gnu.org/licenses/>. */
package com.keylesspalace.tusky;
import android.support.v7.widget.RecyclerView;
import android.view.View;
import android.widget.Button;
import android.widget.LinearLayout;
import android.widget.ProgressBar;
public class FooterViewHolder extends RecyclerView.ViewHolder {
private LinearLayout retryBar;
private Button retry;
private ProgressBar progressBar;
public FooterViewHolder(View itemView) {
super(itemView);
retryBar = (LinearLayout) itemView.findViewById(R.id.footer_retry_bar);
retry = (Button) itemView.findViewById(R.id.footer_retry_button);
progressBar = (ProgressBar) itemView.findViewById(R.id.footer_progress_bar);
progressBar.setIndeterminate(true);
}
public void setupButton(final FooterActionListener listener) {
retry.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
listener.onLoadMore();
}
});
}
public void showRetry(boolean show) {
if (!show) {
retryBar.setVisibility(View.GONE);
progressBar.setVisibility(View.VISIBLE);
} else {
retryBar.setVisibility(View.VISIBLE);
progressBar.setVisibility(View.GONE);
}
}
}

View File

@ -55,4 +55,10 @@ public class Notification {
public void setStatus(Status status) {
this.status = status;
}
public boolean hasStatusType() {
return type == Type.MENTION
|| type == Type.FAVOURITE
|| type == Type.REBLOG;
}
}

View File

@ -16,39 +16,128 @@
package com.keylesspalace.tusky;
import android.content.Context;
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.ImageView;
import android.widget.TextView;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
public class NotificationsAdapter extends RecyclerView.Adapter {
private List<Notification> notifications = new ArrayList<>();
public class NotificationsAdapter extends RecyclerView.Adapter implements AdapterItemRemover {
private static final int VIEW_TYPE_MENTION = 0;
private static final int VIEW_TYPE_FOOTER = 1;
private static final int VIEW_TYPE_STATUS_NOTIFICATION = 2;
private static final int VIEW_TYPE_FOLLOW = 3;
private List<Notification> notifications;
private StatusActionListener statusListener;
private FooterActionListener footerListener;
public NotificationsAdapter(StatusActionListener statusListener,
FooterActionListener footerListener) {
super();
notifications = new ArrayList<>();
this.statusListener = statusListener;
this.footerListener = footerListener;
}
@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);
switch (viewType) {
default:
case VIEW_TYPE_MENTION: {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_status, parent, false);
return new StatusViewHolder(view);
}
case VIEW_TYPE_FOOTER: {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_footer, parent, false);
return new FooterViewHolder(view);
}
case VIEW_TYPE_STATUS_NOTIFICATION: {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_status_notification, parent, false);
return new StatusNotificationViewHolder(view);
}
case VIEW_TYPE_FOLLOW: {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_follow, parent, false);
return new FollowViewHolder(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());
if (position < notifications.size()) {
Notification notification = notifications.get(position);
Notification.Type type = notification.getType();
switch (type) {
case MENTION: {
StatusViewHolder holder = (StatusViewHolder) viewHolder;
Status status = notification.getStatus();
assert(status != null);
holder.setupWithStatus(status, statusListener, position);
break;
}
case FAVOURITE:
case REBLOG: {
StatusNotificationViewHolder holder = (StatusNotificationViewHolder) viewHolder;
holder.setMessage(type, notification.getDisplayName(),
notification.getStatus());
break;
}
case FOLLOW: {
FollowViewHolder holder = (FollowViewHolder) viewHolder;
holder.setMessage(notification.getDisplayName());
break;
}
}
} else {
FooterViewHolder holder = (FooterViewHolder) viewHolder;
holder.setupButton(footerListener);
}
}
@Override
public int getItemCount() {
return notifications.size();
return notifications.size() + 1;
}
public Notification getItem(int position) {
return notifications.get(position);
@Override
public int getItemViewType(int position) {
if (position == notifications.size()) {
return VIEW_TYPE_FOOTER;
} else {
Notification notification = notifications.get(position);
switch (notification.getType()) {
default:
case MENTION: {
return VIEW_TYPE_MENTION;
}
case FAVOURITE:
case REBLOG: {
return VIEW_TYPE_STATUS_NOTIFICATION;
}
case FOLLOW: {
return VIEW_TYPE_FOLLOW;
}
}
}
}
public @Nullable Notification getItem(int position) {
if (position >= 0 && position < notifications.size()) {
return notifications.get(position);
}
return null;
}
public int update(List<Notification> new_notifications) {
@ -76,39 +165,62 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
notifyItemRangeInserted(end, new_notifications.size());
}
public static class ViewHolder extends RecyclerView.ViewHolder {
public void removeItem(int position) {
notifications.remove(position);
notifyItemChanged(position);
}
public static class FollowViewHolder extends RecyclerView.ViewHolder {
private TextView message;
public ViewHolder(View itemView) {
public FollowViewHolder(View itemView) {
super(itemView);
message = (TextView) itemView.findViewById(R.id.notification_text);
}
public void setMessage(Notification.Type type, String displayName) {
public void setMessage(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;
}
}
String format = context.getString(R.string.notification_follow_format);
String wholeMessage = String.format(format, displayName);
message.setText(wholeMessage);
}
}
public static class StatusNotificationViewHolder extends RecyclerView.ViewHolder {
private TextView message;
private ImageView icon;
private TextView statusContent;
public StatusNotificationViewHolder(View itemView) {
super(itemView);
message = (TextView) itemView.findViewById(R.id.notification_text);
icon = (ImageView) itemView.findViewById(R.id.notification_icon);
statusContent = (TextView) itemView.findViewById(R.id.notification_content);
}
public void setMessage(Notification.Type type, String displayName, Status status) {
Context context = message.getContext();
String format;
switch (type) {
default:
case FAVOURITE: {
icon.setImageResource(R.drawable.ic_favourited);
format = context.getString(R.string.notification_favourite_format);
break;
}
case REBLOG: {
icon.setImageResource(R.drawable.ic_reblogged);
format = context.getString(R.string.notification_reblog_format);
break;
}
}
String wholeMessage = String.format(format, displayName);
message.setText(wholeMessage);
String timestamp = DateUtils.getRelativeTimeSpanString(
status.getCreatedAt().getTime(),
new Date().getTime());
statusContent.setText(String.format("%s: ", timestamp));
statusContent.append(status.getContent());
}
}
}

View File

@ -172,12 +172,10 @@ public class NotificationsFragment extends SFragment implements
}
}
@Override
public void onRefresh() {
sendFetchNotificationsRequest();
}
@Override
public void onLoadMore() {
Notification notification = adapter.getItem(adapter.getItemCount() - 2);
if (notification != null) {
@ -187,32 +185,32 @@ public class NotificationsFragment extends SFragment implements
}
}
@Override
public void onReply(int position) {
Notification notification = adapter.getItem(position);
super.reply(notification.getStatus());
}
@Override
public void onReblog(boolean reblog, int position) {
Notification notification = adapter.getItem(position);
super.reblog(notification.getStatus(), reblog, adapter, position);
}
@Override
public void onFavourite(boolean favourite, int position) {
Notification notification = adapter.getItem(position);
super.favourite(notification.getStatus(), favourite, adapter, position);
}
@Override
public void onMore(View view, int position) {
Notification notification = adapter.getItem(position);
super.more(notification.getStatus(), view, adapter, position);
}
@Override
public void onViewMedia(String url, Status.MediaAttachment.Type type) {
super.viewMedia(url, type);
}
public void onViewThread(int position) {
Notification notification = adapter.getItem(position);
super.viewThread(notification.getStatus());
}
}

View File

@ -0,0 +1,247 @@
/* Copyright 2017 Andrew Dawson
*
* This file is part of Tusky.
*
* Tusky is free software: you can redistribute it and/or modify it under the terms of the GNU
* General Public License as published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky. If not, see
* <http://www.gnu.org/licenses/>. */
package com.keylesspalace.tusky;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.support.annotation.Nullable;
import android.support.v4.app.Fragment;
import android.support.v4.app.FragmentManager;
import android.support.v7.widget.PopupMenu;
import android.support.v7.widget.RecyclerView;
import android.view.MenuItem;
import android.view.View;
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.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/* Note from Andrew on Jan. 22, 2017: This class is a design problem for me, so I left it with an
* awkward name. TimelineFragment and NotificationFragment have significant overlap but the nature
* of that is complicated by how they're coupled with Status and Notification and the corresponding
* adapters. I feel like the profile pages and thread viewer, which I haven't made yet, will also
* overlap functionality. So, I'm momentarily leaving it and hopefully working on those will clear
* up what needs to be where. */
public class SFragment extends Fragment {
protected String domain;
protected String accessToken;
protected String loggedInAccountId;
protected String loggedInUsername;
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
SharedPreferences preferences = getContext().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);
sendUserInfoRequest();
}
protected 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);
}
protected void postRequest(String endpoint) {
sendRequest(Request.Method.POST, endpoint, null, null);
}
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 {
loggedInAccountId = response.getString("id");
loggedInUsername = response.getString("acct");
} catch (JSONException e) {
//TODO: Help
assert(false);
}
}
});
}
protected void reply(Status status) {
String inReplyToId = status.getId();
Status.Mention[] mentions = status.getMentions();
List<String> mentionedUsernames = new ArrayList<>();
for (int i = 0; i < mentions.length; i++) {
mentionedUsernames.add(mentions[i].getUsername());
}
mentionedUsernames.add(status.getUsername());
mentionedUsernames.remove(loggedInUsername);
Intent intent = new Intent(getContext(), ComposeActivity.class);
intent.putExtra("in_reply_to_id", inReplyToId);
intent.putExtra("mentioned_usernames", mentionedUsernames.toArray(new String[0]));
startActivity(intent);
}
protected void reblog(final Status status, final boolean reblog,
final RecyclerView.Adapter adapter, final int 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);
}
});
}
protected void favourite(final Status status, final boolean favourite,
final RecyclerView.Adapter adapter, final int 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);
}
protected void more(Status status, View view, final AdapterItemRemover adapter,
final int 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 (loggedInAccountId == null || !loggedInAccountId.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();
}
protected void viewMedia(String url, Status.MediaAttachment.Type type) {
switch (type) {
case IMAGE: {
Fragment newFragment = ViewMediaFragment.newInstance(url);
FragmentManager manager = getFragmentManager();
manager.beginTransaction()
.add(R.id.overlay_fragment_container, newFragment)
.addToBackStack(null)
.commit();
break;
}
case VIDEO: {
Intent intent = new Intent(getContext(), ViewVideoActivity.class);
intent.putExtra("url", url);
startActivity(intent);
break;
}
}
}
protected void viewThread(Status status) {
Intent intent = new Intent(getContext(), ViewThreadActivity.class);
intent.putExtra("id", status.getId());
startActivity(intent);
}
}

View File

@ -23,4 +23,5 @@ public interface StatusActionListener {
void onFavourite(final boolean favourite, final int position);
void onMore(View view, final int position);
void onViewMedia(String url, Status.MediaAttachment.Type type);
void onViewThread(int position);
}

View File

@ -0,0 +1,266 @@
/* Copyright 2017 Andrew Dawson
*
* This file is part of Tusky.
*
* Tusky is free software: you can redistribute it and/or modify it under the terms of the GNU
* General Public License as published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky. If not, see
* <http://www.gnu.org/licenses/>. */
package com.keylesspalace.tusky;
import android.content.Context;
import android.support.annotation.Nullable;
import android.support.v7.widget.RecyclerView;
import android.text.Spanned;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.TextView;
import com.android.volley.toolbox.ImageLoader;
import com.android.volley.toolbox.NetworkImageView;
import java.util.Date;
public class StatusViewHolder extends RecyclerView.ViewHolder {
private View container;
private TextView displayName;
private TextView username;
private TextView sinceCreated;
private TextView content;
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;
private NetworkImageView mediaPreview0;
private NetworkImageView mediaPreview1;
private NetworkImageView mediaPreview2;
private NetworkImageView mediaPreview3;
private View sensitiveMediaWarning;
public StatusViewHolder(View itemView) {
super(itemView);
container = itemView.findViewById(R.id.status_container);
displayName = (TextView) itemView.findViewById(R.id.status_display_name);
username = (TextView) itemView.findViewById(R.id.status_username);
sinceCreated = (TextView) itemView.findViewById(R.id.status_since_created);
content = (TextView) itemView.findViewById(R.id.status_content);
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);
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;
mediaPreview0 = (NetworkImageView) itemView.findViewById(R.id.status_media_preview_0);
mediaPreview1 = (NetworkImageView) itemView.findViewById(R.id.status_media_preview_1);
mediaPreview2 = (NetworkImageView) itemView.findViewById(R.id.status_media_preview_2);
mediaPreview3 = (NetworkImageView) itemView.findViewById(R.id.status_media_preview_3);
mediaPreview0.setDefaultImageResId(R.drawable.media_preview_unloaded);
mediaPreview1.setDefaultImageResId(R.drawable.media_preview_unloaded);
mediaPreview2.setDefaultImageResId(R.drawable.media_preview_unloaded);
mediaPreview3.setDefaultImageResId(R.drawable.media_preview_unloaded);
sensitiveMediaWarning = itemView.findViewById(R.id.status_sensitive_media_warning);
}
public void setDisplayName(String name) {
displayName.setText(name);
}
public void setUsername(String name) {
Context context = username.getContext();
String format = context.getString(R.string.status_username_format);
String usernameText = String.format(format, name);
username.setText(usernameText);
}
public void setContent(Spanned content) {
this.content.setText(content);
}
public void setAvatar(String url) {
Context context = avatar.getContext();
ImageLoader imageLoader = VolleySingleton.getInstance(context).getImageLoader();
avatar.setImageUrl(url, imageLoader);
avatar.setDefaultImageResId(R.drawable.avatar_default);
avatar.setErrorImageResId(R.drawable.avatar_error);
}
public void setCreatedAt(@Nullable Date createdAt) {
String readout;
if (createdAt != null) {
long then = createdAt.getTime();
long now = new Date().getTime();
readout = DateUtils.getRelativeTimeSpanString(then, now);
} else {
readout = "?m"; // unknown minutes~
}
sinceCreated.setText(readout);
}
public void setRebloggedByUsername(String name) {
Context context = boostedByUsername.getContext();
String format = context.getString(R.string.status_boosted_format);
String boostedText = String.format(format, name);
boostedByUsername.setText(boostedText);
boostedIcon.setVisibility(View.VISIBLE);
boostedByUsername.setVisibility(View.VISIBLE);
}
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 setMediaPreviews(final Status.MediaAttachment[] attachments,
boolean sensitive, final StatusActionListener listener) {
final NetworkImageView[] previews = {
mediaPreview0,
mediaPreview1,
mediaPreview2,
mediaPreview3
};
Context context = mediaPreview0.getContext();
ImageLoader imageLoader = VolleySingleton.getInstance(context).getImageLoader();
final int n = Math.min(attachments.length, Status.MAX_MEDIA_ATTACHMENTS);
for (int i = 0; i < n; i++) {
String previewUrl = attachments[i].getPreviewUrl();
previews[i].setImageUrl(previewUrl, imageLoader);
if (!sensitive) {
previews[i].setVisibility(View.VISIBLE);
} else {
previews[i].setVisibility(View.GONE);
}
final String url = attachments[i].getUrl();
final Status.MediaAttachment.Type type = attachments[i].getType();
previews[i].setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
listener.onViewMedia(url, type);
}
});
}
if (sensitive) {
sensitiveMediaWarning.setVisibility(View.VISIBLE);
sensitiveMediaWarning.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
v.setVisibility(View.GONE);
for (int i = 0; i < n; i++) {
previews[i].setVisibility(View.VISIBLE);
}
v.setOnClickListener(null);
}
});
}
// Hide any of the placeholder previews beyond the ones set.
for (int i = n; i < Status.MAX_MEDIA_ATTACHMENTS; i++) {
previews[i].setImageUrl(null, imageLoader);
previews[i].setVisibility(View.GONE);
}
}
public void hideSensitiveMediaWarning() {
sensitiveMediaWarning.setVisibility(View.GONE);
}
public void setupButtons(final StatusActionListener listener, final int position) {
replyButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
listener.onReply(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);
}
});
container.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
listener.onViewThread(position);
}
});
}
public void setupWithStatus(Status status, StatusActionListener listener, int position) {
setDisplayName(status.getDisplayName());
setUsername(status.getUsername());
setCreatedAt(status.getCreatedAt());
setContent(status.getContent());
setAvatar(status.getAvatar());
setContent(status.getContent());
setReblogged(status.getReblogged());
setFavourited(status.getFavourited());
String rebloggedByUsername = status.getRebloggedByUsername();
if (rebloggedByUsername == null) {
hideRebloggedByUsername();
} else {
setRebloggedByUsername(rebloggedByUsername);
}
Status.MediaAttachment[] attachments = status.getAttachments();
boolean sensitive = status.getSensitive();
setMediaPreviews(attachments, sensitive, listener);
/* A status without attachments is sometimes still marked sensitive, so it's necessary to
* check both whether there are any attachments and if it's marked sensitive. */
if (!sensitive || attachments.length == 0) {
hideSensitiveMediaWarning();
}
setupButtons(listener, position);
if (status.getVisibility() == Status.Visibility.PRIVATE) {
disableReblogging();
}
}
}

View File

@ -0,0 +1,83 @@
/* Copyright 2017 Andrew Dawson
*
* This file is part of Tusky.
*
* Tusky is free software: you can redistribute it and/or modify it under the terms of the GNU
* General Public License as published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky. If not, see
* <http://www.gnu.org/licenses/>. */
package com.keylesspalace.tusky;
import android.support.v7.widget.RecyclerView;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import java.util.ArrayList;
import java.util.List;
public class ThreadAdapter extends RecyclerView.Adapter implements AdapterItemRemover {
private List<Status> statuses;
private StatusActionListener statusActionListener;
private int statusIndex;
public ThreadAdapter(StatusActionListener listener) {
this.statusActionListener = listener;
this.statuses = new ArrayList<>();
this.statusIndex = 0;
}
@Override
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_status, parent, false);
return new StatusViewHolder(view);
}
@Override
public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) {
StatusViewHolder holder = (StatusViewHolder) viewHolder;
Status status = statuses.get(position);
holder.setupWithStatus(status, statusActionListener, position);
}
@Override
public int getItemCount() {
return statuses.size();
}
public Status getItem(int position) {
return statuses.get(position);
}
public void removeItem(int position) {
statuses.remove(position);
notifyItemRemoved(position);
}
public int insertStatus(Status status) {
int i = statusIndex;
statuses.add(i, status);
notifyItemInserted(i);
return i;
}
public void addAncestors(List<Status> ancestors) {
statusIndex = ancestors.size();
statuses.addAll(0, ancestors);
notifyItemRangeInserted(0, statusIndex);
}
public void addDescendants(List<Status> descendants) {
int end = statuses.size();
statuses.addAll(descendants);
notifyItemRangeInserted(end, descendants.size());
}
}

View File

@ -15,30 +15,16 @@
package com.keylesspalace.tusky;
import android.content.Context;
import android.support.annotation.Nullable;
import android.support.v7.widget.PagerSnapHelper;
import android.support.v7.widget.RecyclerView;
import android.text.Spanned;
import android.text.style.ImageSpan;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.ProgressBar;
import android.widget.TextView;
import com.android.volley.toolbox.ImageLoader;
import com.android.volley.toolbox.NetworkImageView;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
public class TimelineAdapter extends RecyclerView.Adapter {
public class TimelineAdapter extends RecyclerView.Adapter implements AdapterItemRemover {
private static final int VIEW_TYPE_STATUS = 0;
private static final int VIEW_TYPE_FOOTER = 1;
@ -76,32 +62,7 @@ public class TimelineAdapter extends RecyclerView.Adapter {
if (position < statuses.size()) {
StatusViewHolder holder = (StatusViewHolder) viewHolder;
Status status = statuses.get(position);
holder.setDisplayName(status.getDisplayName());
holder.setUsername(status.getUsername());
holder.setCreatedAt(status.getCreatedAt());
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.hideRebloggedByUsername();
} else {
holder.setRebloggedByUsername(rebloggedByUsername);
}
Status.MediaAttachment[] attachments = status.getAttachments();
boolean sensitive = status.getSensitive();
holder.setMediaPreviews(attachments, sensitive, statusListener);
/* A status without attachments is sometimes still marked sensitive, so it's necessary
* to check both whether there are any attachments and if it's marked sensitive. */
if (!sensitive || attachments.length == 0) {
holder.hideSensitiveMediaWarning();
}
holder.setupButtons(statusListener, position);
if (status.getVisibility() == Status.Visibility.PRIVATE) {
holder.disableReblogging();
}
holder.setupWithStatus(status, statusListener, position);
} else {
FooterViewHolder holder = (FooterViewHolder) viewHolder;
holder.setupButton(footerListener);
@ -158,267 +119,4 @@ public class TimelineAdapter extends RecyclerView.Adapter {
}
return null;
}
public static class StatusViewHolder extends RecyclerView.ViewHolder {
private TextView displayName;
private TextView username;
private TextView sinceCreated;
private TextView content;
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;
private NetworkImageView mediaPreview0;
private NetworkImageView mediaPreview1;
private NetworkImageView mediaPreview2;
private NetworkImageView mediaPreview3;
private View sensitiveMediaWarning;
public StatusViewHolder(View itemView) {
super(itemView);
displayName = (TextView) itemView.findViewById(R.id.status_display_name);
username = (TextView) itemView.findViewById(R.id.status_username);
sinceCreated = (TextView) itemView.findViewById(R.id.status_since_created);
content = (TextView) itemView.findViewById(R.id.status_content);
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);
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;
mediaPreview0 = (NetworkImageView) itemView.findViewById(R.id.status_media_preview_0);
mediaPreview1 = (NetworkImageView) itemView.findViewById(R.id.status_media_preview_1);
mediaPreview2 = (NetworkImageView) itemView.findViewById(R.id.status_media_preview_2);
mediaPreview3 = (NetworkImageView) itemView.findViewById(R.id.status_media_preview_3);
mediaPreview0.setDefaultImageResId(R.drawable.media_preview_unloaded);
mediaPreview1.setDefaultImageResId(R.drawable.media_preview_unloaded);
mediaPreview2.setDefaultImageResId(R.drawable.media_preview_unloaded);
mediaPreview3.setDefaultImageResId(R.drawable.media_preview_unloaded);
sensitiveMediaWarning = itemView.findViewById(R.id.status_sensitive_media_warning);
}
public void setDisplayName(String name) {
displayName.setText(name);
}
public void setUsername(String name) {
Context context = username.getContext();
String format = context.getString(R.string.status_username_format);
String usernameText = String.format(format, name);
username.setText(usernameText);
}
public void setContent(Spanned content) {
this.content.setText(content);
}
public void setAvatar(String url) {
Context context = avatar.getContext();
ImageLoader imageLoader = VolleySingleton.getInstance(context).getImageLoader();
avatar.setImageUrl(url, imageLoader);
avatar.setDefaultImageResId(R.drawable.avatar_default);
avatar.setErrorImageResId(R.drawable.avatar_error);
}
/* This is a rough duplicate of android.text.format.DateUtils.getRelativeTimeSpanString,
* but even with the FORMAT_ABBREV_RELATIVE flag it wasn't abbreviating enough. */
private String getRelativeTimeSpanString(long then, long now) {
final long MINUTE = 60;
final long HOUR = 60 * MINUTE;
final long DAY = 24 * HOUR;
final long YEAR = 365 * DAY;
long span = (now - then) / 1000;
String prefix = "";
if (span < 0) {
prefix = "in ";
span = -span;
}
String unit;
if (span < MINUTE) {
unit = "s";
} else if (span < HOUR) {
span /= MINUTE;
unit = "m";
} else if (span < DAY) {
span /= HOUR;
unit = "h";
} else if (span < YEAR) {
span /= DAY;
unit = "d";
} else {
span /= YEAR;
unit = "y";
}
return prefix + span + unit;
}
public void setCreatedAt(@Nullable Date createdAt) {
String readout;
if (createdAt != null) {
long then = createdAt.getTime();
long now = new Date().getTime();
readout = getRelativeTimeSpanString(then, now);
} else {
readout = "?m"; // unknown minutes~
}
sinceCreated.setText(readout);
}
public void setRebloggedByUsername(String name) {
Context context = boostedByUsername.getContext();
String format = context.getString(R.string.status_boosted_format);
String boostedText = String.format(format, name);
boostedByUsername.setText(boostedText);
boostedIcon.setVisibility(View.VISIBLE);
boostedByUsername.setVisibility(View.VISIBLE);
}
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 setMediaPreviews(final Status.MediaAttachment[] attachments,
boolean sensitive, final StatusActionListener listener) {
final NetworkImageView[] previews = {
mediaPreview0,
mediaPreview1,
mediaPreview2,
mediaPreview3
};
Context context = mediaPreview0.getContext();
ImageLoader imageLoader = VolleySingleton.getInstance(context).getImageLoader();
final int n = Math.min(attachments.length, Status.MAX_MEDIA_ATTACHMENTS);
for (int i = 0; i < n; i++) {
String previewUrl = attachments[i].getPreviewUrl();
previews[i].setImageUrl(previewUrl, imageLoader);
if (!sensitive) {
previews[i].setVisibility(View.VISIBLE);
} else {
previews[i].setVisibility(View.GONE);
}
final String url = attachments[i].getUrl();
final Status.MediaAttachment.Type type = attachments[i].getType();
previews[i].setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
listener.onViewMedia(url, type);
}
});
}
if (sensitive) {
sensitiveMediaWarning.setVisibility(View.VISIBLE);
sensitiveMediaWarning.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
v.setVisibility(View.GONE);
for (int i = 0; i < n; i++) {
previews[i].setVisibility(View.VISIBLE);
}
v.setOnClickListener(null);
}
});
}
// Hide any of the placeholder previews beyond the ones set.
for (int i = n; i < Status.MAX_MEDIA_ATTACHMENTS; i++) {
previews[i].setImageUrl(null, imageLoader);
previews[i].setVisibility(View.GONE);
}
}
public void hideSensitiveMediaWarning() {
sensitiveMediaWarning.setVisibility(View.GONE);
}
public void setupButtons(final StatusActionListener listener, final int position) {
replyButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
listener.onReply(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);
}
});
}
}
public static class FooterViewHolder extends RecyclerView.ViewHolder {
private LinearLayout retryBar;
private Button retry;
private ProgressBar progressBar;
public FooterViewHolder(View itemView) {
super(itemView);
retryBar = (LinearLayout) itemView.findViewById(R.id.footer_retry_bar);
retry = (Button) itemView.findViewById(R.id.footer_retry_button);
progressBar = (ProgressBar) itemView.findViewById(R.id.footer_progress_bar);
progressBar.setIndeterminate(true);
}
public void setupButton(final FooterActionListener listener) {
retry.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
listener.onLoadMore();
}
});
}
public void showRetry(boolean show) {
if (!show) {
retryBar.setVisibility(View.GONE);
progressBar.setVisibility(View.VISIBLE);
} else {
retryBar.setVisibility(View.VISIBLE);
progressBar.setVisibility(View.GONE);
}
}
}
}

View File

@ -16,42 +16,31 @@
package com.keylesspalace.tusky;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.support.annotation.Nullable;
import android.support.design.widget.TabLayout;
import android.support.v4.app.Fragment;
import android.support.v4.app.FragmentManager;
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 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.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class TimelineFragment extends Fragment implements
public class TimelineFragment extends SFragment implements
SwipeRefreshLayout.OnRefreshListener, StatusActionListener, FooterActionListener {
public enum Kind {
@ -60,12 +49,6 @@ public class TimelineFragment extends Fragment implements
PUBLIC,
}
private String domain = null;
private String accessToken = null;
/** ID of the account that is currently logged-in. */
private String userAccountId = null;
/** Username of the account that is currently logged-in. */
private String userUsername = null;
private SwipeRefreshLayout swipeRefreshLayout;
private RecyclerView recyclerView;
private TimelineAdapter adapter;
@ -90,15 +73,8 @@ public class TimelineFragment extends Fragment implements
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.
Context context = getContext();
swipeRefreshLayout = (SwipeRefreshLayout) rootView.findViewById(R.id.swipe_refresh_layout);
swipeRefreshLayout.setOnRefreshListener(this);
// Setup the RecyclerView.
@ -121,7 +97,6 @@ public class TimelineFragment extends Fragment implements
} else {
sendFetchTimelineRequest();
}
}
};
recyclerView.addOnScrollListener(scrollListener);
@ -143,7 +118,6 @@ public class TimelineFragment extends Fragment implements
};
layout.addOnTabSelectedListener(onTabSelectedListener);
sendUserInfoRequest();
sendFetchTimelineRequest();
return rootView;
@ -161,22 +135,6 @@ public class TimelineFragment extends Fragment implements
scrollListener.reset();
}
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");
userUsername = response.getString("acct");
} catch (JSONException e) {
//TODO: Help
assert(false);
}
}
});
}
private void sendFetchTimelineRequest(final String fromId) {
String endpoint;
switch (kind) {
@ -251,7 +209,7 @@ public class TimelineFragment extends Fragment implements
RecyclerView.ViewHolder viewHolder =
recyclerView.findViewHolderForAdapterPosition(adapter.getItemCount() - 1);
if (viewHolder != null) {
TimelineAdapter.FooterViewHolder holder = (TimelineAdapter.FooterViewHolder) viewHolder;
FooterViewHolder holder = (FooterViewHolder) viewHolder;
holder.showRetry(show);
}
}
@ -260,163 +218,6 @@ public class TimelineFragment extends Fragment implements
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 onReply(int position) {
Status status = adapter.getItem(position);
String inReplyToId = status.getId();
Status.Mention[] mentions = status.getMentions();
List<String> mentionedUsernames = new ArrayList<>();
for (int i = 0; i < mentions.length; i++) {
mentionedUsernames.add(mentions[i].getUsername());
}
mentionedUsernames.add(status.getUsername());
mentionedUsernames.remove(userUsername);
Intent intent = new Intent(getContext(), ComposeActivity.class);
intent.putExtra("in_reply_to_id", inReplyToId);
intent.putExtra("mentioned_usernames", mentionedUsernames.toArray(new String[0]));
startActivity(intent);
}
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();
}
public void onViewMedia(String url, Status.MediaAttachment.Type type) {
switch (type) {
case IMAGE: {
Fragment newFragment = ViewMediaFragment.newInstance(url);
FragmentManager manager = getFragmentManager();
manager.beginTransaction()
.add(R.id.overlay_fragment_container, newFragment)
.addToBackStack(null)
.commit();
break;
}
case VIDEO: {
Intent intent = new Intent(getContext(), ViewVideoActivity.class);
intent.putExtra("url", url);
startActivity(intent);
break;
}
}
}
public void onLoadMore() {
Status status = adapter.getItem(adapter.getItemCount() - 2);
if (status != null) {
@ -425,4 +226,28 @@ public class TimelineFragment extends Fragment implements
sendFetchTimelineRequest();
}
}
public void onReply(int position) {
super.reply(adapter.getItem(position));
}
public void onReblog(final boolean reblog, final int position) {
super.reblog(adapter.getItem(position), reblog, adapter, position);
}
public void onFavourite(final boolean favourite, final int position) {
super.favourite(adapter.getItem(position), favourite, adapter, position);
}
public void onMore(View view, final int position) {
super.more(adapter.getItem(position), view, adapter, position);
}
public void onViewMedia(String url, Status.MediaAttachment.Type type) {
super.viewMedia(url, type);
}
public void onViewThread(int position) {
super.viewThread(adapter.getItem(position));
}
}

View File

@ -0,0 +1,68 @@
/* Copyright 2017 Andrew Dawson
*
* This file is part of Tusky.
*
* Tusky is free software: you can redistribute it and/or modify it under the terms of the GNU
* General Public License as published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky. If not, see
* <http://www.gnu.org/licenses/>. */
package com.keylesspalace.tusky;
import android.content.Intent;
import android.os.Bundle;
import android.support.annotation.Nullable;
import android.support.v4.app.Fragment;
import android.support.v4.app.FragmentTransaction;
import android.support.v7.app.ActionBar;
import android.support.v7.app.AppCompatActivity;
import android.support.v7.widget.Toolbar;
import android.view.Menu;
import android.view.MenuItem;
public class ViewThreadActivity extends AppCompatActivity {
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_view_thread);
Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
setSupportActionBar(toolbar);
ActionBar bar = getSupportActionBar();
if (bar != null) {
bar.setTitle(R.string.title_thread);
}
String id = getIntent().getStringExtra("id");
FragmentTransaction fragmentTransaction = getSupportFragmentManager().beginTransaction();
Fragment fragment = ViewThreadFragment.newInstance(id);
fragmentTransaction.add(R.id.fragment_container, fragment);
fragmentTransaction.commit();
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
getMenuInflater().inflate(R.menu.view_thread_toolbar, menu);
return super.onCreateOptionsMenu(menu);
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case R.id.action_back: {
Intent intent = new Intent(this, MainActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
startActivity(intent);
return true;
}
}
return super.onOptionsItemSelected(item);
}
}

View File

@ -0,0 +1,143 @@
/* Copyright 2017 Andrew Dawson
*
* This file is part of Tusky.
*
* Tusky is free software: you can redistribute it and/or modify it under the terms of the GNU
* General Public License as published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky. If not, see
* <http://www.gnu.org/licenses/>. */
package com.keylesspalace.tusky;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.support.annotation.Nullable;
import android.support.v4.content.ContextCompat;
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 com.android.volley.Request;
import com.android.volley.Response;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.List;
public class ViewThreadFragment extends SFragment implements StatusActionListener {
private RecyclerView recyclerView;
private ThreadAdapter adapter;
public static ViewThreadFragment newInstance(String id) {
Bundle arguments = new Bundle();
ViewThreadFragment fragment = new ViewThreadFragment();
arguments.putString("id", id);
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_view_thread, container, false);
Context context = getContext();
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);
adapter = new ThreadAdapter(this);
recyclerView.setAdapter(adapter);
String id = getArguments().getString("id");
sendStatusRequest(id);
sendThreadRequest(id);
return rootView;
}
private void sendStatusRequest(String id) {
String endpoint = String.format(getString(R.string.endpoint_get_status), id);
super.sendRequest(Request.Method.GET, endpoint, null,
new Response.Listener<JSONObject>() {
@Override
public void onResponse(JSONObject response) {
Status status;
try {
status = Status.parse(response, false);
} catch (JSONException e) {
onThreadRequestFailure();
return;
}
int position = adapter.insertStatus(status);
recyclerView.scrollToPosition(position);
}
});
}
private void sendThreadRequest(String id) {
String endpoint = String.format(getString(R.string.endpoint_context), id);
super.sendRequest(Request.Method.GET, endpoint, null,
new Response.Listener<JSONObject>() {
@Override
public void onResponse(JSONObject response) {
try {
List<Status> ancestors =
Status.parse(response.getJSONArray("ancestors"));
List<Status> descendants =
Status.parse(response.getJSONArray("descendants"));
adapter.addAncestors(ancestors);
adapter.addDescendants(descendants);
} catch (JSONException e) {
onThreadRequestFailure();
}
}
});
}
private void onThreadRequestFailure() {
//TODO: no
assert(false);
}
public void onReply(int position) {
super.reply(adapter.getItem(position));
}
public void onReblog(boolean reblog, int position) {
super.reblog(adapter.getItem(position), reblog, adapter, position);
}
public void onFavourite(boolean favourite, int position) {
super.favourite(adapter.getItem(position), favourite, adapter, position);
}
public void onMore(View view, int position) {
super.more(adapter.getItem(position), view, adapter, position);
}
public void onViewMedia(String url, Status.MediaAttachment.Type type) {
super.viewMedia(url, type);
}
public void onViewThread(int position) {
super.viewThread(adapter.getItem(position));
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 B

View File

@ -0,0 +1,7 @@
<vector android:height="24dp" android:viewportHeight="850.3937"
android:viewportWidth="850.3937" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillAlpha="1" android:fillColor="#ffffff"
android:pathData="m410.4,48.8c-9.1,0 -18.1,3.5 -25.1,10.4L84.7,359.9c-1.6,1.6 -2.9,3.2 -4.1,5 -6,6.3 -9.7,14.9 -9.7,24.4l0,70.9c0,11.3 5.2,21.3 13.4,27.8 1.6,3.2 3.8,6.2 6.5,8.9L391.5,797.5c13.9,13.9 36.2,13.9 50.1,0l50.1,-50.1c13.9,-13.9 13.9,-36.2 0,-50.1l-201.7,-201.7 454.1,0c19.6,0 35.4,-15.8 35.4,-35.4l0,-70.9c0,-19.6 -15.8,-35.4 -35.4,-35.4l-452.9,0 194.4,-194.4c13.9,-13.9 13.9,-36.2 0,-50.1l-50.1,-50.1c-6.9,-6.9 -16,-10.4 -25.1,-10.4z"
android:strokeAlpha="1" android:strokeColor="#00000000"
android:strokeLineCap="butt" android:strokeLineJoin="round" android:strokeWidth="10.62992096"/>
</vector>

View File

@ -0,0 +1,7 @@
<vector android:height="16dp" android:viewportHeight="566.92914"
android:viewportWidth="566.92914" android:width="16dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillAlpha="1" android:fillColor="#000000"
android:pathData="M141.7,0C63.2,0 0,63.2 0,141.7L0,425.2C0,503.7 63.2,566.9 141.7,566.9L425.2,566.9C503.7,566.9 566.9,503.7 566.9,425.2L566.9,141.7C566.9,63.2 503.7,0 425.2,0L141.7,0zM283.6,24.8C287.6,24.9 291.2,27.1 293,30.7L363.4,173.4L520.9,196.3C529.6,197.6 533.1,208.3 526.8,214.4L412.8,325.5L439.7,482.3C441.2,491 432.1,497.6 424.3,493.5L283.5,419.5L142.6,493.5C134.8,497.6 125.7,491 127.2,482.3L154.1,325.5L40.2,214.4C33.8,208.3 37.3,197.6 46,196.3L203.5,173.4L273.9,30.7C275.7,27.1 279.5,24.8 283.6,24.8z"
android:strokeAlpha="1" android:strokeColor="#9d9d9d"
android:strokeLineCap="butt" android:strokeLineJoin="round" android:strokeWidth="0"/>
</vector>

View File

@ -0,0 +1,7 @@
<vector android:height="16dp" android:viewportHeight="566.92914"
android:viewportWidth="566.92914" android:width="16dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillAlpha="1" android:fillColor="#000000"
android:pathData="M141.7,0C63.2,0 0,63.2 0,141.7L0,425.2C0,503.7 63.2,566.9 141.7,566.9L425.2,566.9C503.7,566.9 566.9,503.7 566.9,425.2L566.9,141.7C566.9,63.2 503.7,0 425.2,0L141.7,0zM283.5,70.9A88.6,88.6 0,0 1,372 159.4A88.6,88.6 0,0 1,283.5 248A88.6,88.6 0,0 1,194.9 159.4A88.6,88.6 0,0 1,283.5 70.9zM194.9,311.3C194.9,311.3 229.1,336.6 283.5,336.6C338.4,336.6 370.5,311.3 370.5,311.3C496.1,407.5 460.6,478.3 460.6,478.3L106.3,478.3C106.3,478.3 70.9,407.5 194.9,311.3z"
android:strokeAlpha="1" android:strokeColor="#9d9d9d"
android:strokeLineCap="butt" android:strokeLineJoin="round" android:strokeWidth="0"/>
</vector>

View File

@ -0,0 +1,7 @@
<vector android:height="16dp" android:viewportHeight="566.92914"
android:viewportWidth="566.92914" android:width="16dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillAlpha="1" android:fillColor="#000000"
android:pathData="M141.7,0C63.2,0 0,63.2 0,141.7L0,425.2C0,503.7 63.2,566.9 141.7,566.9L425.2,566.9C503.7,566.9 566.9,503.7 566.9,425.2L566.9,141.7C566.9,63.2 503.7,0 425.2,0L141.7,0zM177.2,124L354.3,124C432.9,124 496.1,182.6 496.1,265.7L496.1,336.6L549.2,336.6L460.6,425.2L372,336.6L425.2,336.6L425.2,265.7C425.2,226.4 393.6,194.9 354.3,194.9L248,194.9L177.2,124zM106.3,141.7L194.9,230.3L141.7,230.3L141.7,301.2C141.7,340.5 173.3,372 212.6,372L318.9,372L389.8,442.9L212.6,442.9C134,442.9 70.9,384.3 70.9,301.2L70.9,230.3L17.7,230.3L106.3,141.7z"
android:strokeAlpha="1" android:strokeColor="#9d9d9d"
android:strokeLineCap="butt" android:strokeLineJoin="round" android:strokeWidth="0"/>
</vector>

View File

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/activity_view_thread"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.keylesspalace.tusky.ViewThreadActivity">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<android.support.v7.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorPrimary"
android:elevation="4dp"
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
app:popupTheme="@style/ThemeOverlay.AppCompat.Light" />
<FrameLayout
android:id="@+id/fragment_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
<FrameLayout
android:id="@+id/overlay_fragment_container"
android:layout_width="match_parent"
android:layout_height="match_parent">
</FrameLayout>
</RelativeLayout>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<android.support.v7.widget.RecyclerView
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="vertical" />

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<RelativeLayout
android:layout_width="@dimen/status_avatar_column_width"
android:layout_height="wrap_content"
android:id="@+id/notification_side_column">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:srcCompat="@drawable/ic_followed"
android:paddingTop="@dimen/notification_icon_vertical_padding"
android:paddingBottom="@dimen/notification_icon_vertical_padding"
android:paddingRight="@dimen/status_avatar_padding"
android:layout_alignParentRight="true" />
</RelativeLayout>
<TextView
android:layout_toRightOf="@id/notification_side_column"
android:id="@+id/notification_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="@dimen/notification_icon_vertical_padding"
android:layout_alignParentBottom="true" />
</RelativeLayout>

View File

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical" android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/notification_text"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
android:layout_height="wrap_content"
android:id="@+id/status_container">
<TextView
android:layout_width="wrap_content"
@ -17,7 +17,7 @@
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:srcCompat="@drawable/boost_icon"
app:srcCompat="@drawable/ic_reblogged"
android:id="@+id/status_boosted_icon"
android:adjustViewBounds="false"
android:cropToPadding="false"

View File

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<RelativeLayout
android:layout_width="@dimen/status_avatar_column_width"
android:layout_height="wrap_content"
android:id="@+id/notification_side_column">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/notification_icon"
android:paddingTop="@dimen/notification_icon_vertical_padding"
android:paddingBottom="@dimen/notification_icon_vertical_padding"
android:paddingRight="@dimen/status_avatar_padding"
android:layout_alignParentRight="true" />
</RelativeLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/notification_text"
android:layout_toRightOf="@id/notification_side_column"
android:paddingBottom="@dimen/notification_icon_vertical_padding" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/notification_content"
android:layout_toRightOf="@id/notification_side_column"
android:layout_below="@id/notification_text"
android:textColor="@color/notification_content_faded"
android:paddingBottom="8dp" />
</RelativeLayout>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item android:id="@+id/action_back"
android:title="@string/action_back"
android:icon="@drawable/ic_back"
app:showAsAction="always" />
</menu>

View File

@ -8,4 +8,5 @@
<color name="sensitive_media_warning_background">#303030</color>
<color name="media_preview_unloaded_background">#DFDFDF</color>
<color name="compose_mention">#4F5F6F</color>
<color name="notification_content_faded">#9F9F9F</color>
</resources>

View File

@ -3,6 +3,7 @@
<dimen name="activity_vertical_margin">0dp</dimen>
<dimen name="status_username_left_margin">4dp</dimen>
<dimen name="status_since_created_left_margin">4dp</dimen>
<dimen name="status_avatar_column_width">56dp</dimen>
<dimen name="status_avatar_padding">8dp</dimen>
<dimen name="status_boost_icon_vertical_padding">5dp</dimen>
<dimen name="status_media_preview_top_margin">4dp</dimen>
@ -12,4 +13,5 @@
<dimen name="compose_media_preview_margin_bottom">16dp</dimen>
<dimen name="compose_media_preview_side">48dp</dimen>
<dimen name="compose_mark_sensitive_margin">8dp</dimen>
<dimen name="notification_icon_vertical_padding">4dp</dimen>
</resources>

View File

@ -52,6 +52,7 @@
<string name="title_home">Home</string>
<string name="title_notifications">Notifications</string>
<string name="title_public">Public</string>
<string name="title_thread">Thread</string>
<string name="status_username_format">\@%s</string>
<string name="status_boosted_format">%s boosted</string>
@ -60,8 +61,8 @@
<string name="footer_text">Could not load the rest of the toots.</string>
<string name="notification_reblog_format">%s boosted your status</string>
<string name="notification_favourite_format">%s favourited your status</string>
<string name="notification_reblog_format">%s boosted your toot</string>
<string name="notification_favourite_format">%s favourited your toot</string>
<string name="notification_follow_format">%s followed you</string>
<string name="action_compose">Compose</string>
@ -74,6 +75,9 @@
<string name="action_retry">Retry</string>
<string name="action_mark_sensitive">Mark Sensitive</string>
<string name="action_cancel">Cancel</string>
<string name="action_back">Back</string>
<string name="confirmation_send">Toot!</string>
<string name="description_domain">Domain</string>
<string name="description_compose">What\'s Happening?</string>