diff --git a/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java b/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java index fca70e577..5b9a86d93 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java @@ -26,7 +26,10 @@ import android.support.v4.app.ActivityCompat; import android.support.v4.content.ContextCompat; import android.support.v7.app.AppCompatActivity; import android.text.Editable; +import android.text.Spannable; +import android.text.Spanned; import android.text.TextWatcher; +import android.text.style.ForegroundColorSpan; import android.view.View; import android.webkit.MimeTypeMap; import android.widget.Button; @@ -54,19 +57,25 @@ import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; import java.util.Date; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Random; +import java.util.regex.Matcher; +import java.util.regex.Pattern; public class ComposeActivity extends AppCompatActivity { private static final int STATUS_CHARACTER_LIMIT = 500; private static final int STATUS_MEDIA_SIZE_LIMIT = 4000000; // 4MB private static final int MEDIA_PICK_RESULT = 1; private static final int PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1; + private static final Pattern mentionPattern = Pattern.compile("\\B@[^\\s@]+@?[^\\s@]+"); + private String inReplyToId; private String domain; private String accessToken; private EditText textEditor; @@ -148,113 +157,58 @@ public class ComposeActivity extends AppCompatActivity { Snackbar.make(findViewById(R.id.activity_compose), stringId, Snackbar.LENGTH_LONG).show(); } - private void onSendSuccess() { - Toast.makeText(this, "Toot!", Toast.LENGTH_SHORT).show(); - finish(); + private static class Interval { + public int start; + public int end; } - private void onSendFailure(Exception exception) { - textEditor.setError(getString(R.string.error_sending_status)); - } - - private void sendStatus(String content, String visibility, boolean sensitive) { - 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); - parameters.put("sensitive", sensitive); - JSONArray media_ids = new JSONArray(); - for (QueuedMedia item : mediaQueued) { - media_ids.put(item.getId()); - } - if (media_ids.length() > 0) { - parameters.put("media_ids", media_ids); - } - } catch (JSONException e) { - onSendFailure(e); - return; + private static void colourMentions(Spannable text, int colour) { + // Strip all existing colour spans. + int n = text.length(); + ForegroundColorSpan[] oldSpans = text.getSpans(0, n, ForegroundColorSpan.class); + for (int i = oldSpans.length - 1; i >= 0; i--) { + text.removeSpan(oldSpans[i]); } - JsonObjectRequest request = new JsonObjectRequest(Request.Method.POST, url, parameters, - new Response.Listener() { - @Override - public void onResponse(JSONObject response) { - onSendSuccess(); - } - }, new Response.ErrorListener() { - @Override - public void onErrorResponse(VolleyError error) { - onSendFailure(error); - } - }) { + // Match a list of new colour spans. + List intervals = new ArrayList<>(); + Matcher matcher = mentionPattern.matcher(text); + while (matcher.find()) { + Interval interval = new Interval(); + interval.start = matcher.start(); + interval.end = matcher.end(); + intervals.add(interval); + } + // Make sure intervals don't overlap. + Collections.sort(intervals, new Comparator() { @Override - public Map getHeaders() throws AuthFailureError { - Map headers = new HashMap<>(); - headers.put("Authorization", "Bearer " + accessToken); - return headers; - } - }; - VolleySingleton.getInstance(this).addToRequestQueue(request); - } - - private void readyStatus(final String content, final String visibility, - final boolean sensitive) { - final ProgressDialog dialog = ProgressDialog.show(this, "Finishing Media Upload", - "Uploading...", true, true); - final AsyncTask waitForMediaTask = - new AsyncTask() { - private Exception exception; - - @Override - protected Boolean doInBackground(Void... params) { - try { - waitForMediaLatch.await(); - } catch (InterruptedException e) { - exception = e; - return false; - } - return true; - } - - @Override - protected void onPostExecute(Boolean successful) { - super.onPostExecute(successful); - dialog.dismiss(); - if (successful) { - sendStatus(content, visibility, sensitive); - } else { - onReadyFailure(exception, content, visibility, sensitive); - } - } - - @Override - protected void onCancelled() { - removeAllMediaFromQueue(); - super.onCancelled(); - } - }; - dialog.setOnCancelListener(new DialogInterface.OnCancelListener() { - @Override - public void onCancel(DialogInterface dialog) { - /* Generating an interrupt by passing true here is important because an interrupt - * exception is the only thing that will kick the latch out of its waiting loop - * early. */ - waitForMediaTask.cancel(true); + public int compare(Interval a, Interval b) { + return a.start - b.start; } }); - waitForMediaTask.execute(); - } - - private void onReadyFailure(Exception exception, final String content, final String visibility, - final boolean sensitive) { - doErrorDialog(R.string.error_media_upload_sending, R.string.action_retry, - new View.OnClickListener() { - @Override - public void onClick(View v) { - readyStatus(content, visibility, sensitive); + for (int i = 0, j = 0; i < intervals.size() - 1; i++, j++) { + if (j != 0) { + Interval a = intervals.get(j - 1); + Interval b = intervals.get(i); + if (a.start <= b.end) { + while (j != 0 && a.start <= b.end) { + a = intervals.get(j - 1); + b = intervals.get(i); + a.end = Math.max(a.end, b.end); + a.start = Math.min(a.start, b.start); + j--; } - }); + } else { + intervals.set(j, b); + } + } else { + intervals.set(j, intervals.get(i)); + } + } + // Finally, set the spans. + for (Interval interval : intervals) { + text.setSpan(new ForegroundColorSpan(colour), interval.start, interval.end, + Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + } } @Override @@ -262,6 +216,13 @@ public class ComposeActivity extends AppCompatActivity { super.onCreate(savedInstanceState); setContentView(R.layout.activity_compose); + Intent intent = getIntent(); + String[] mentionedUsernames = null; + if (intent != null) { + inReplyToId = intent.getStringExtra("in_reply_to_id"); + mentionedUsernames = intent.getStringArrayExtra("mentioned_usernames"); + } + SharedPreferences preferences = getSharedPreferences( getString(R.string.preferences_file_key), Context.MODE_PRIVATE); domain = preferences.getString("domain", null); @@ -271,6 +232,7 @@ public class ComposeActivity extends AppCompatActivity { textEditor = (EditText) findViewById(R.id.field_status); final TextView charactersLeft = (TextView) findViewById(R.id.characters_left); + final int mentionColour = ContextCompat.getColor(this, R.color.compose_mention); TextWatcher textEditorWatcher = new TextWatcher() { @Override public void onTextChanged(CharSequence s, int start, int before, int count) { @@ -282,10 +244,23 @@ public class ComposeActivity extends AppCompatActivity { public void beforeTextChanged(CharSequence s, int start, int count, int after) {} @Override - public void afterTextChanged(Editable s) {} + public void afterTextChanged(Editable editable) { + colourMentions(editable, mentionColour); + } }; textEditor.addTextChangedListener(textEditorWatcher); + if (mentionedUsernames != null) { + StringBuilder builder = new StringBuilder(); + for (String name : mentionedUsernames) { + builder.append('@'); + builder.append(name); + builder.append(' '); + } + textEditor.setText(builder); + textEditor.setSelection(textEditor.length()); + } + mediaPreviewBar = (LinearLayout) findViewById(R.id.compose_media_preview_bar); mediaQueued = new ArrayList<>(); waitForMediaLatch = new CountUpDownLatch(); @@ -332,6 +307,118 @@ public class ComposeActivity extends AppCompatActivity { markSensitive.setVisibility(View.GONE); } + 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, boolean sensitive) { + 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); + parameters.put("sensitive", sensitive); + if (inReplyToId != null) { + parameters.put("in_reply_to_id", inReplyToId); + } + JSONArray media_ids = new JSONArray(); + for (QueuedMedia item : mediaQueued) { + media_ids.put(item.getId()); + } + if (media_ids.length() > 0) { + parameters.put("media_ids", media_ids); + } + } catch (JSONException e) { + onSendFailure(e); + return; + } + JsonObjectRequest request = new JsonObjectRequest(Request.Method.POST, url, parameters, + new Response.Listener() { + @Override + public void onResponse(JSONObject response) { + onSendSuccess(); + } + }, new Response.ErrorListener() { + @Override + public void onErrorResponse(VolleyError error) { + onSendFailure(error); + } + }) { + @Override + public Map getHeaders() throws AuthFailureError { + Map headers = new HashMap<>(); + headers.put("Authorization", "Bearer " + accessToken); + return headers; + } + }; + VolleySingleton.getInstance(this).addToRequestQueue(request); + } + + private void readyStatus(final String content, final String visibility, + final boolean sensitive) { + final ProgressDialog dialog = ProgressDialog.show(this, "Finishing Media Upload", + "Uploading...", true, true); + final AsyncTask waitForMediaTask = + new AsyncTask() { + private Exception exception; + + @Override + protected Boolean doInBackground(Void... params) { + try { + waitForMediaLatch.await(); + } catch (InterruptedException e) { + exception = e; + return false; + } + return true; + } + + @Override + protected void onPostExecute(Boolean successful) { + super.onPostExecute(successful); + dialog.dismiss(); + if (successful) { + sendStatus(content, visibility, sensitive); + } else { + onReadyFailure(exception, content, visibility, sensitive); + } + } + + @Override + protected void onCancelled() { + removeAllMediaFromQueue(); + super.onCancelled(); + } + }; + dialog.setOnCancelListener(new DialogInterface.OnCancelListener() { + @Override + public void onCancel(DialogInterface dialog) { + /* Generating an interrupt by passing true here is important because an interrupt + * exception is the only thing that will kick the latch out of its waiting loop + * early. */ + waitForMediaTask.cancel(true); + } + }); + waitForMediaTask.execute(); + } + + private void onReadyFailure(Exception exception, final String content, final String visibility, + final boolean sensitive) { + doErrorDialog(R.string.error_media_upload_sending, R.string.action_retry, + new View.OnClickListener() { + @Override + public void onClick(View v) { + readyStatus(content, visibility, sensitive); + } + }); + } + private void onMediaPick() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN && ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) @@ -468,11 +555,10 @@ public class ComposeActivity extends AppCompatActivity { private void downsizeMedia(final QueuedMedia item) { item.setReadyStage(QueuedMedia.ReadyStage.DOWNSIZING); - InputStream stream = null; + InputStream stream; try { stream = getContentResolver().openInputStream(item.getUri()); } catch (FileNotFoundException e) { - IOUtils.closeQuietly(stream); onMediaDownsizeFailure(item); return; } @@ -567,11 +653,10 @@ public class ComposeActivity extends AppCompatActivity { public DataItem getData() { byte[] content = item.getContent(); if (content == null) { - InputStream stream = null; + InputStream stream; try { stream = getContentResolver().openInputStream(item.getUri()); } catch (FileNotFoundException e) { - IOUtils.closeQuietly(stream); return null; } content = inputStreamGetBytes(stream); @@ -638,11 +723,10 @@ public class ComposeActivity extends AppCompatActivity { break; } case "image": { - InputStream stream = null; + InputStream stream; try { stream = contentResolver.openInputStream(uri); } catch (FileNotFoundException e) { - IOUtils.closeQuietly(stream); displayTransientError(R.string.error_media_upload_opening); return; } diff --git a/app/src/main/java/com/keylesspalace/tusky/Status.java b/app/src/main/java/com/keylesspalace/tusky/Status.java index bb0935748..d3c36704c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/Status.java +++ b/app/src/main/java/com/keylesspalace/tusky/Status.java @@ -43,6 +43,7 @@ public class Status { private boolean favourited; private Visibility visibility; private MediaAttachment[] attachments; + private Mention[] mentions; private boolean sensitive; public static final int MAX_MEDIA_ATTACHMENTS = 4; @@ -61,6 +62,7 @@ public class Status { this.favourited = favourited; this.visibility = Visibility.valueOf(visibility.toUpperCase()); this.attachments = new MediaAttachment[0]; + this.mentions = new Mention[0]; } public String getId() { @@ -111,6 +113,10 @@ public class Status { return attachments; } + public Mention[] getMentions() { + return mentions; + } + public boolean getSensitive() { return sensitive; } @@ -127,6 +133,10 @@ public class Status { this.favourited = favourited; } + public void setMentions(Mention[] mentions) { + this.mentions = mentions; + } + public void setAttachments(MediaAttachment[] attachments, boolean sensitive) { this.attachments = attachments; this.sensitive = sensitive; @@ -196,6 +206,20 @@ public class Status { String username = account.getString("acct"); String avatar = account.getString("avatar"); + JSONArray mentionsArray = object.getJSONArray("mentions"); + Mention[] mentions = null; + if (mentionsArray != null) { + int n = mentionsArray.length(); + mentions = new Mention[n]; + for (int i = 0; i < n; i++) { + JSONObject mention = mentionsArray.getJSONObject(i); + String url = mention.getString("url"); + String mentionedUsername = mention.getString("acct"); + String mentionedAccountId = mention.getString("id"); + mentions[i] = new Mention(url, mentionedUsername, mentionedAccountId); + } + } + JSONArray mediaAttachments = object.getJSONArray("media_attachments"); MediaAttachment[] attachments = null; if (mediaAttachments != null) { @@ -230,9 +254,12 @@ public class Status { status = new Status( id, accountId, displayName, username, contentPlus, avatar, createdAt, reblogged, favourited, visibility); - } - if (attachments != null) { - status.setAttachments(attachments, sensitive); + if (mentions != null) { + status.setMentions(mentions); + } + if (attachments != null) { + status.setAttachments(attachments, sensitive); + } } return status; } @@ -274,4 +301,28 @@ public class Status { return type; } } + + public static class Mention { + private String url; + private String username; + private String id; + + public Mention(String url, String username, String id) { + this.url = url; + this.username = username; + this.id = id; + } + + public String getUrl() { + return url; + } + + public String getUsername() { + return username; + } + + public String getId() { + return id; + } + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/StatusActionListener.java b/app/src/main/java/com/keylesspalace/tusky/StatusActionListener.java index b43883e02..0b477d857 100644 --- a/app/src/main/java/com/keylesspalace/tusky/StatusActionListener.java +++ b/app/src/main/java/com/keylesspalace/tusky/StatusActionListener.java @@ -3,6 +3,7 @@ package com.keylesspalace.tusky; import android.view.View; public interface StatusActionListener { + void onReply(int position); void onReblog(final boolean reblog, final int position); void onFavourite(final boolean favourite, final int position); void onMore(View view, final int position); diff --git a/app/src/main/java/com/keylesspalace/tusky/TimelineAdapter.java b/app/src/main/java/com/keylesspalace/tusky/TimelineAdapter.java index 45ef42980..0ca2417d1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TimelineAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/TimelineAdapter.java @@ -347,6 +347,12 @@ public class TimelineAdapter extends RecyclerView.Adapter { } 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) { diff --git a/app/src/main/java/com/keylesspalace/tusky/TimelineFragment.java b/app/src/main/java/com/keylesspalace/tusky/TimelineFragment.java index 4d161a814..438fd6b8d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TimelineFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/TimelineFragment.java @@ -31,6 +31,7 @@ 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; @@ -46,7 +47,10 @@ public class TimelineFragment extends Fragment implements 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; @@ -149,6 +153,7 @@ public class TimelineFragment extends Fragment implements public void onResponse(JSONObject response) { try { userAccountId = response.getString("id"); + userUsername = response.getString("acct"); } catch (JSONException e) { //TODO: Help assert(false); @@ -273,6 +278,22 @@ public class TimelineFragment extends Fragment implements 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 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(); diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index a7e7dde9a..1f98e43a5 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -7,4 +7,5 @@ #000000 #303030 #DFDFDF + #4F5F6F