Added content warnings to status composer and slightly reworked its design in general.

This commit is contained in:
Vavassor 2017-02-03 19:53:33 -05:00
parent 3bd360a7ee
commit 86623c634a
8 changed files with 294 additions and 91 deletions

View File

@ -33,6 +33,7 @@ import android.net.Uri;
import android.os.AsyncTask; import android.os.AsyncTask;
import android.os.Build; import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.os.Parcel;
import android.provider.OpenableColumns; import android.provider.OpenableColumns;
import android.support.annotation.NonNull; import android.support.annotation.NonNull;
import android.support.annotation.Nullable; import android.support.annotation.Nullable;
@ -95,10 +96,14 @@ public class ComposeActivity extends AppCompatActivity {
private String accessToken; private String accessToken;
private EditText textEditor; private EditText textEditor;
private ImageButton mediaPick; private ImageButton mediaPick;
private CheckBox markSensitive;
private LinearLayout mediaPreviewBar; private LinearLayout mediaPreviewBar;
private List<QueuedMedia> mediaQueued; private List<QueuedMedia> mediaQueued;
private CountUpDownLatch waitForMediaLatch; private CountUpDownLatch waitForMediaLatch;
private boolean showMarkSensitive;
private String statusVisibility; // The current values of the options that will be applied
private boolean statusMarkSensitive; // to the status being composed.
private boolean statusHideText; //
private View contentWarningBar;
private static class QueuedMedia { private static class QueuedMedia {
public enum Type { public enum Type {
@ -242,8 +247,6 @@ public class ComposeActivity extends AppCompatActivity {
getString(R.string.preferences_file_key), Context.MODE_PRIVATE); getString(R.string.preferences_file_key), Context.MODE_PRIVATE);
domain = preferences.getString("domain", null); domain = preferences.getString("domain", null);
accessToken = preferences.getString("accessToken", null); accessToken = preferences.getString("accessToken", null);
assert(domain != null);
assert(accessToken != null);
textEditor = (EditText) findViewById(R.id.field_status); textEditor = (EditText) findViewById(R.id.field_status);
final TextView charactersLeft = (TextView) findViewById(R.id.characters_left); final TextView charactersLeft = (TextView) findViewById(R.id.characters_left);
@ -280,31 +283,22 @@ public class ComposeActivity extends AppCompatActivity {
mediaQueued = new ArrayList<>(); mediaQueued = new ArrayList<>();
waitForMediaLatch = new CountUpDownLatch(); waitForMediaLatch = new CountUpDownLatch();
final RadioGroup radio = (RadioGroup) findViewById(R.id.radio_visibility); contentWarningBar = findViewById(R.id.compose_content_warning_bar);
final EditText contentWarningEditor = (EditText) findViewById(R.id.field_content_warning);
showContentWarning(false);
final Button sendButton = (Button) findViewById(R.id.button_send); final Button sendButton = (Button) findViewById(R.id.button_send);
sendButton.setOnClickListener(new View.OnClickListener() { sendButton.setOnClickListener(new View.OnClickListener() {
@Override @Override
public void onClick(View v) { public void onClick(View v) {
Editable editable = textEditor.getText(); Editable editable = textEditor.getText();
if (editable.length() <= STATUS_CHARACTER_LIMIT) { if (editable.length() <= STATUS_CHARACTER_LIMIT) {
int id = radio.getCheckedRadioButtonId(); String spoilerText = "";
String visibility; if (statusHideText) {
switch (id) { spoilerText = contentWarningEditor.getText().toString();
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;
}
} }
readyStatus(editable.toString(), visibility, markSensitive.isChecked()); readyStatus(editable.toString(), statusVisibility, statusMarkSensitive,
spoilerText);
} else { } else {
textEditor.setError(getString(R.string.error_compose_character_limit)); textEditor.setError(getString(R.string.error_compose_character_limit));
} }
@ -318,20 +312,45 @@ public class ComposeActivity extends AppCompatActivity {
onMediaPick(); onMediaPick();
} }
}); });
markSensitive = (CheckBox) findViewById(R.id.compose_mark_sensitive);
markSensitive.setVisibility(View.GONE); ImageButton options = (ImageButton) findViewById(R.id.compose_options);
options.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
ComposeOptionsFragment fragment = ComposeOptionsFragment.newInstance(
statusVisibility, statusMarkSensitive, statusHideText,
showMarkSensitive,
new ComposeOptionsFragment.Listener() {
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {}
@Override
public void onVisibilityChanged(String visibility) {
statusVisibility = visibility;
}
@Override
public void onMarkSensitiveChanged(boolean markSensitive) {
statusMarkSensitive = markSensitive;
}
@Override
public void onContentWarningChanged(boolean hideText) {
showContentWarning(hideText);
}
});
fragment.show(getSupportFragmentManager(), null);
}
});
} }
private void onSendSuccess() { private void sendStatus(String content, String visibility, boolean sensitive,
Toast.makeText(this, getString(R.string.confirmation_send), Toast.LENGTH_SHORT).show(); String spoilerText) {
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 endpoint = getString(R.string.endpoint_status);
String url = "https://" + domain + endpoint; String url = "https://" + domain + endpoint;
JSONObject parameters = new JSONObject(); JSONObject parameters = new JSONObject();
@ -339,6 +358,7 @@ public class ComposeActivity extends AppCompatActivity {
parameters.put("status", content); parameters.put("status", content);
parameters.put("visibility", visibility); parameters.put("visibility", visibility);
parameters.put("sensitive", sensitive); parameters.put("sensitive", sensitive);
parameters.put("spoiler_text", spoilerText);
if (inReplyToId != null) { if (inReplyToId != null) {
parameters.put("in_reply_to_id", inReplyToId); parameters.put("in_reply_to_id", inReplyToId);
} }
@ -350,7 +370,7 @@ public class ComposeActivity extends AppCompatActivity {
parameters.put("media_ids", media_ids); parameters.put("media_ids", media_ids);
} }
} catch (JSONException e) { } catch (JSONException e) {
onSendFailure(e); onSendFailure();
return; return;
} }
JsonObjectRequest request = new JsonObjectRequest(Request.Method.POST, url, parameters, JsonObjectRequest request = new JsonObjectRequest(Request.Method.POST, url, parameters,
@ -362,7 +382,7 @@ public class ComposeActivity extends AppCompatActivity {
}, new Response.ErrorListener() { }, new Response.ErrorListener() {
@Override @Override
public void onErrorResponse(VolleyError error) { public void onErrorResponse(VolleyError error) {
onSendFailure(error); onSendFailure();
} }
}) { }) {
@Override @Override
@ -375,20 +395,26 @@ public class ComposeActivity extends AppCompatActivity {
VolleySingleton.getInstance(this).addToRequestQueue(request); VolleySingleton.getInstance(this).addToRequestQueue(request);
} }
private void onSendSuccess() {
Toast.makeText(this, getString(R.string.confirmation_send), Toast.LENGTH_SHORT).show();
finish();
}
private void onSendFailure() {
textEditor.setError(getString(R.string.error_sending_status));
}
private void readyStatus(final String content, final String visibility, private void readyStatus(final String content, final String visibility,
final boolean sensitive) { final boolean sensitive, final String spoilerText) {
final ProgressDialog dialog = ProgressDialog.show(this, "Finishing Media Upload", final ProgressDialog dialog = ProgressDialog.show(this, "Finishing Media Upload",
"Uploading...", true, true); "Uploading...", true, true);
final AsyncTask<Void, Void, Boolean> waitForMediaTask = final AsyncTask<Void, Void, Boolean> waitForMediaTask =
new AsyncTask<Void, Void, Boolean>() { new AsyncTask<Void, Void, Boolean>() {
private Exception exception;
@Override @Override
protected Boolean doInBackground(Void... params) { protected Boolean doInBackground(Void... params) {
try { try {
waitForMediaLatch.await(); waitForMediaLatch.await();
} catch (InterruptedException e) { } catch (InterruptedException e) {
exception = e;
return false; return false;
} }
return true; return true;
@ -399,9 +425,9 @@ public class ComposeActivity extends AppCompatActivity {
super.onPostExecute(successful); super.onPostExecute(successful);
dialog.dismiss(); dialog.dismiss();
if (successful) { if (successful) {
sendStatus(content, visibility, sensitive); sendStatus(content, visibility, sensitive, spoilerText);
} else { } else {
onReadyFailure(exception, content, visibility, sensitive); onReadyFailure(content, visibility, sensitive, spoilerText);
} }
} }
@ -423,13 +449,13 @@ public class ComposeActivity extends AppCompatActivity {
waitForMediaTask.execute(); waitForMediaTask.execute();
} }
private void onReadyFailure(Exception exception, final String content, final String visibility, private void onReadyFailure(final String content, final String visibility,
final boolean sensitive) { final boolean sensitive, final String spoilerText) {
doErrorDialog(R.string.error_media_upload_sending, R.string.action_retry, doErrorDialog(R.string.error_media_upload_sending, R.string.action_retry,
new View.OnClickListener() { new View.OnClickListener() {
@Override @Override
public void onClick(View v) { public void onClick(View v) {
readyStatus(content, visibility, sensitive); readyStatus(content, visibility, sensitive, spoilerText);
} }
}); });
} }
@ -499,7 +525,6 @@ public class ComposeActivity extends AppCompatActivity {
} }
private void addMediaToQueue(QueuedMedia.Type type, Bitmap preview, Uri uri, long mediaSize) { private void addMediaToQueue(QueuedMedia.Type type, Bitmap preview, Uri uri, long mediaSize) {
assert(mediaQueued.size() < Status.MAX_MEDIA_ATTACHMENTS);
final QueuedMedia item = new QueuedMedia(type, uri, new ImageView(this)); final QueuedMedia item = new QueuedMedia(type, uri, new ImageView(this));
ImageView view = item.getPreview(); ImageView view = item.getPreview();
Resources resources = getResources(); Resources resources = getResources();
@ -536,7 +561,7 @@ public class ComposeActivity extends AppCompatActivity {
disableMediaPicking(); disableMediaPicking();
} }
if (queuedCount >= 1) { if (queuedCount >= 1) {
markSensitive.setVisibility(View.VISIBLE); showMarkSensitive(true);
} }
waitForMediaLatch.countUp(); waitForMediaLatch.countUp();
if (mediaSize > STATUS_MEDIA_SIZE_LIMIT && type == QueuedMedia.Type.IMAGE) { if (mediaSize > STATUS_MEDIA_SIZE_LIMIT && type == QueuedMedia.Type.IMAGE) {
@ -551,7 +576,7 @@ public class ComposeActivity extends AppCompatActivity {
mediaPreviewBar.removeView(item.getPreview()); mediaPreviewBar.removeView(item.getPreview());
mediaQueued.remove(item); mediaQueued.remove(item);
if (mediaQueued.size() == 0) { if (mediaQueued.size() == 0) {
markSensitive.setVisibility(View.GONE); showMarkSensitive(false);
/* If there are no image previews to show, the extra padding that was added to the /* If there are no image previews to show, the extra padding that was added to the
* EditText can be removed so there isn't unnecessary empty space. */ * EditText can be removed so there isn't unnecessary empty space. */
setPaddingRelative(textEditor, 0, 0, 0, moveBottom); setPaddingRelative(textEditor, 0, 0, 0, moveBottom);
@ -646,7 +671,7 @@ public class ComposeActivity extends AppCompatActivity {
try { try {
item.setId(response.getString("id")); item.setId(response.getString("id"));
} catch (JSONException e) { } catch (JSONException e) {
onUploadFailure(item, e); onUploadFailure(item);
return; return;
} }
waitForMediaLatch.countDown(); waitForMediaLatch.countDown();
@ -654,7 +679,7 @@ public class ComposeActivity extends AppCompatActivity {
}, new Response.ErrorListener() { }, new Response.ErrorListener() {
@Override @Override
public void onErrorResponse(VolleyError error) { public void onErrorResponse(VolleyError error) {
onUploadFailure(item, error); onUploadFailure(item);
} }
}) { }) {
@Override @Override
@ -692,7 +717,7 @@ public class ComposeActivity extends AppCompatActivity {
VolleySingleton.getInstance(this).addToRequestQueue(request); VolleySingleton.getInstance(this).addToRequestQueue(request);
} }
private void onUploadFailure(QueuedMedia item, @Nullable Exception exception) { private void onUploadFailure(QueuedMedia item) {
displayTransientError(R.string.error_media_upload_sending); displayTransientError(R.string.error_media_upload_sending);
removeMediaFromQueue(item); removeMediaFromQueue(item);
} }
@ -770,4 +795,20 @@ public class ComposeActivity extends AppCompatActivity {
} }
} }
} }
void showMarkSensitive(boolean show) {
showMarkSensitive = show;
if(!showMarkSensitive) {
statusMarkSensitive = false;
}
}
void showContentWarning(boolean show) {
statusHideText = show;
if (show) {
contentWarningBar.setVisibility(View.VISIBLE);
} else {
contentWarningBar.setVisibility(View.GONE);
}
}
} }

View File

@ -0,0 +1,107 @@
package com.keylesspalace.tusky;
import android.os.Bundle;
import android.os.Parcelable;
import android.support.annotation.Nullable;
import android.support.design.widget.BottomSheetDialogFragment;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.CheckBox;
import android.widget.CompoundButton;
import android.widget.RadioGroup;
public class ComposeOptionsFragment extends BottomSheetDialogFragment {
public interface Listener extends Parcelable {
void onVisibilityChanged(String visibility);
void onMarkSensitiveChanged(boolean markSensitive);
void onContentWarningChanged(boolean hideText);
}
private Listener listener;
public static ComposeOptionsFragment newInstance(String visibility, boolean markSensitive,
boolean hideText, boolean showMarkSensitive, Listener listener) {
Bundle arguments = new Bundle();
ComposeOptionsFragment fragment = new ComposeOptionsFragment();
arguments.putParcelable("listener", listener);
arguments.putString("visibility", visibility);
arguments.putBoolean("markSensitive", markSensitive);
arguments.putBoolean("hideText", hideText);
arguments.putBoolean("showMarkSensitive", showMarkSensitive);
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_compose_options, container, false);
Bundle arguments = getArguments();
listener = arguments.getParcelable("listener");
String statusVisibility = arguments.getString("visibility");
boolean statusMarkSensitive = arguments.getBoolean("markSensitive");
boolean statusHideText = arguments.getBoolean("hideText");
boolean showMarkSensitive = arguments.getBoolean("showMarkSensitive");
RadioGroup radio = (RadioGroup) rootView.findViewById(R.id.radio_visibility);
int radioCheckedId = R.id.radio_public;
if (statusVisibility != null) {
if (statusVisibility.equals("unlisted")) {
radioCheckedId = R.id.radio_unlisted;
} else if (statusVisibility.equals("private")) {
radioCheckedId = R.id.radio_private;
}
}
radio.check(radioCheckedId);
radio.setOnCheckedChangeListener(new RadioGroup.OnCheckedChangeListener() {
@Override
public void onCheckedChanged(RadioGroup group, int checkedId) {
String visibility;
switch (checkedId) {
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;
}
}
listener.onVisibilityChanged(visibility);
}
});
CheckBox markSensitive = (CheckBox) rootView.findViewById(R.id.compose_mark_sensitive);
if (showMarkSensitive) {
markSensitive.setChecked(statusMarkSensitive);
markSensitive.setEnabled(true);
markSensitive.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
@Override
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
listener.onMarkSensitiveChanged(isChecked);
}
});
} else {
markSensitive.setEnabled(false);
}
CheckBox hideText = (CheckBox) rootView.findViewById(R.id.compose_hide_text);
hideText.setChecked(statusHideText);
hideText.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
@Override
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
listener.onContentWarningChanged(isChecked);
}
});
return rootView;
}
}

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="3dp" />
<stroke android:color="#ff000000" android:width="1dp" />
</shape>

View File

@ -0,0 +1,7 @@
<vector android:height="24dp" android:viewportHeight="1133.8583"
android:viewportWidth="1133.8583" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillAlpha="1" android:fillColor="#00000000"
android:pathData="M704.8,566.9A137.9,137.9 0,0 1,566.9 704.8,137.9 137.9,0 0,1 429,566.9 137.9,137.9 0,0 1,566.9 429,137.9 137.9,0 0,1 704.8,566.9ZM566.9,1098.1c-184.7,0 -26,-116.8 -185.9,-209.2 -160,-92.4 -181.8,103.5 -274.1,-56.4 -92.4,-160 88.2,-80.9 88.2,-265.6 0,-184.7 -180.5,-105.6 -88.2,-265.6 92.4,-160 114.1,35.9 274.1,-56.4 160,-92.4 1.2,-209.2 185.9,-209.2 184.7,-0 26,116.8 185.9,209.2 160,92.4 181.8,-103.5 274.1,56.4 92.4,160 -88.2,80.9 -88.2,265.6 0,184.7 180.5,105.6 88.2,265.6C934.6,992.5 912.8,796.6 752.8,888.9 592.9,981.3 751.6,1098.1 566.9,1098.1Z"
android:strokeAlpha="1" android:strokeColor="#000000"
android:strokeLineCap="butt" android:strokeLineJoin="round" android:strokeWidth="68.95068359"/>
</vector>

View File

@ -19,12 +19,31 @@
android:id="@+id/compose_photo_pick" android:id="@+id/compose_photo_pick"
android:layout_marginLeft="8dp" /> android:layout_marginLeft="8dp" />
<CheckBox <ImageButton
android:layout_width="wrap_content" android:layout_width="48dp"
android:layout_height="match_parent" android:layout_height="48dp"
android:layout_margin="@dimen/compose_mark_sensitive_margin" android:id="@+id/compose_options"
android:id="@+id/compose_mark_sensitive" app:srcCompat="@drawable/ic_options"
android:text="@string/action_mark_sensitive" /> style="?android:attr/borderlessButtonStyle"
android:layout_marginLeft="8dp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/compose_content_warning_bar"
android:background="@drawable/border_background"
android:layout_margin="8dp"
android:paddingLeft="8dp"
android:paddingRight="8dp">
<EditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:ems="10"
android:id="@+id/field_content_warning" />
</LinearLayout> </LinearLayout>
@ -59,36 +78,6 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content"> android:layout_height="wrap_content">
<RadioGroup
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:orientation="horizontal"
android:id="@+id/radio_visibility"
android:checkedButton="@+id/radio_public">
<RadioButton
android:text="@string/visibility_public"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:id="@+id/radio_public"
android:layout_weight="1" />
<RadioButton
android:text="@string/visibility_unlisted"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:id="@+id/radio_unlisted"
android:layout_weight="1" />
<RadioButton
android:text="@string/visibility_private"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:id="@+id/radio_private"
android:layout_weight="1" />
</RadioGroup>
<Space <Space
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="match_parent" android:layout_height="match_parent"

View File

@ -0,0 +1,52 @@
<?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="match_parent">
<RadioGroup
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:orientation="vertical"
android:id="@+id/radio_visibility"
android:checkedButton="@+id/radio_public"
android:layout_margin="@dimen/compose_options_margin">
<RadioButton
android:text="@string/visibility_public"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:id="@+id/radio_public"
android:layout_weight="1" />
<RadioButton
android:text="@string/visibility_unlisted"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:id="@+id/radio_unlisted"
android:layout_weight="1" />
<RadioButton
android:text="@string/visibility_private"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:id="@+id/radio_private"
android:layout_weight="1" />
</RadioGroup>
<CheckBox
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_margin="@dimen/compose_options_margin"
android:id="@+id/compose_mark_sensitive"
android:text="@string/action_mark_sensitive" />
<CheckBox
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_margin="@dimen/compose_options_margin"
android:id="@+id/compose_hide_text"
android:text="@string/action_hide_text" />
</LinearLayout>

View File

@ -12,7 +12,7 @@
<dimen name="compose_media_preview_margin">8dp</dimen> <dimen name="compose_media_preview_margin">8dp</dimen>
<dimen name="compose_media_preview_margin_bottom">16dp</dimen> <dimen name="compose_media_preview_margin_bottom">16dp</dimen>
<dimen name="compose_media_preview_side">48dp</dimen> <dimen name="compose_media_preview_side">48dp</dimen>
<dimen name="compose_mark_sensitive_margin">8dp</dimen> <dimen name="compose_options_margin">8dp</dimen>
<dimen name="notification_icon_vertical_padding">4dp</dimen> <dimen name="notification_icon_vertical_padding">4dp</dimen>
<dimen name="account_note_margin">8dp</dimen> <dimen name="account_note_margin">8dp</dimen>
<dimen name="account_avatar_margin">8dp</dimen> <dimen name="account_avatar_margin">8dp</dimen>

View File

@ -88,7 +88,9 @@
<string name="action_delete">Delete</string> <string name="action_delete">Delete</string>
<string name="action_send">TOOT</string> <string name="action_send">TOOT</string>
<string name="action_retry">Retry</string> <string name="action_retry">Retry</string>
<string name="action_mark_sensitive">Mark Sensitive</string> <string name="action_mark_sensitive">Mark media sensitive</string>
<string name="action_hide_text">Hide text behind warning</string>
<string name="action_ok">Ok</string>
<string name="action_cancel">Cancel</string> <string name="action_cancel">Cancel</string>
<string name="action_back">Back</string> <string name="action_back">Back</string>
<string name="action_profile">Profile</string> <string name="action_profile">Profile</string>
@ -99,9 +101,9 @@
<string name="description_domain">Domain</string> <string name="description_domain">Domain</string>
<string name="description_compose">What\'s Happening?</string> <string name="description_compose">What\'s Happening?</string>
<string name="visibility_public">Public</string> <string name="visibility_public">Show on public timeline</string>
<string name="visibility_private">Private</string> <string name="visibility_unlisted">Do not display on public timeline</string>
<string name="visibility_unlisted">Unlisted</string> <string name="visibility_private">Mark as private</string>
<string name="notification_service_description">Allows Tusky to check for Mastodon notifications.</string> <string name="notification_service_description">Allows Tusky to check for Mastodon notifications.</string>
<string name="notification_service_several_mentions">%d new mentions</string> <string name="notification_service_several_mentions">%d new mentions</string>