Notification tweaks: Grouping and Quick Reply button (#587)

* Added notification grouping and Quick Reply button

* Legal stuff

* Coding style

* Check whether account still exists when sending a quick reply

* Add "compose" button

* Polish translation

* Improve strings

* Code style

* Cancel notification when user hits "compose" button

* Notification counter

* Make sure to open ComposeActivity for notification recipient account

* Add ability to request account switch when starting an activity
This commit is contained in:
remi6397 2018-05-06 11:07:10 +02:00 committed by Konrad Pozniak
parent aa48acdbec
commit e8c79cce65
11 changed files with 369 additions and 61 deletions

View file

@ -101,6 +101,11 @@
<receiver android:name=".receiver.NotificationClearBroadcastReceiver" />
<receiver
android:name=".receiver.SendStatusBroadcastReceiver"
android:enabled="true"
android:exported="false" />
<service
tools:targetApi="24"
android:name="com.keylesspalace.tusky.service.TuskyTileService"

View file

@ -88,8 +88,6 @@ public final class AccountActivity extends BaseActivity implements ActionButtonA
@Inject
public MastodonApi mastodonApi;
@Inject
public AccountManager accountManager;
@Inject
public DispatchingAndroidInjector<Fragment> dispatchingAndroidInjector;
private String accountId;

View file

@ -34,8 +34,13 @@ import com.keylesspalace.tusky.db.AccountEntity;
import com.keylesspalace.tusky.db.AccountManager;
import com.keylesspalace.tusky.util.ThemeUtils;
import javax.inject.Inject;
public abstract class BaseActivity extends AppCompatActivity {
@Inject
public AccountManager accountManager;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
@ -48,6 +53,11 @@ public abstract class BaseActivity extends AppCompatActivity {
String theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT);
ThemeUtils.setAppNightMode(theme, this);
long accountId = getIntent().getLongExtra("account", -1);
if (accountId != -1) {
accountManager.setActiveAccount(accountId);
}
int style;
switch (preferences.getString("statusTextSize", "medium")) {
case "large":

View file

@ -167,8 +167,6 @@ public final class ComposeActivity
@Inject
public MastodonApi mastodonApi;
@Inject
public AccountManager accountManager;
private TextView replyTextView;
private TextView replyContentTextView;

View file

@ -92,8 +92,6 @@ public class MainActivity extends BaseActivity implements ActionButtonActivity,
public MastodonApi mastodonApi;
@Inject
public DispatchingAndroidInjector<Fragment> fragmentInjector;
@Inject
public AccountManager accountManager;
private static int COMPOSE_RESULT = 1;

View file

@ -25,6 +25,10 @@ import dagger.android.ContributesAndroidInjector
@Module
abstract class ActivitiesModule {
@ContributesAndroidInjector
abstract fun contributesBaseActivity(): BaseActivity
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
abstract fun contributesMainActivity(): MainActivity

View file

@ -1,4 +1,5 @@
/* Copyright 2018 Conny Duck
/* Copyright 2018 Jeremiasz Nelz <remi6397(a)gmail.com>
* Copyright 2018 Conny Duck
*
* This file is a part of Tusky.
*
@ -15,6 +16,7 @@
package com.keylesspalace.tusky.di
import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver
import com.keylesspalace.tusky.receiver.NotificationClearBroadcastReceiver
import dagger.Module
import dagger.android.ContributesAndroidInjector
@ -22,5 +24,8 @@ import dagger.android.ContributesAndroidInjector
@Module
abstract class BroadcastReceiverModule {
@ContributesAndroidInjector
abstract fun contributeSendStatusBroadcastReceiver() : SendStatusBroadcastReceiver
abstract fun contributeNotificationClearBroadcastReceiver() : NotificationClearBroadcastReceiver
}

View file

@ -0,0 +1,145 @@
/* Copyright 2018 Jeremiasz Nelz <remi6397(a)gmail.com>
*
* This file is a part of Tusky.
*
* This program 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.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.support.v4.app.NotificationCompat
import android.support.v4.app.NotificationManagerCompat
import android.support.v4.app.RemoteInput
import android.support.v4.content.ContextCompat
import android.util.Log
import com.keylesspalace.tusky.ComposeActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.service.SendTootService
import com.keylesspalace.tusky.util.NotificationHelper
import dagger.android.AndroidInjection
import java.util.*
import javax.inject.Inject
private const val TAG = "SendStatusBR"
class SendStatusBroadcastReceiver : BroadcastReceiver() {
@Inject
lateinit var accountManager: AccountManager
override fun onReceive(context: Context, intent: Intent) {
AndroidInjection.inject(this, context)
val notificationId = intent.getIntExtra(NotificationHelper.KEY_NOTIFICATION_ID, -1)
val senderId = intent.getLongExtra(NotificationHelper.KEY_SENDER_ACCOUNT_ID, -1)
val senderIdentifier = intent.getStringExtra(NotificationHelper.KEY_SENDER_ACCOUNT_IDENTIFIER)
val senderFullName = intent.getStringExtra(NotificationHelper.KEY_SENDER_ACCOUNT_FULL_NAME)
val citedStatusId = intent.getStringExtra(NotificationHelper.KEY_CITED_STATUS_ID)
val visibility = intent.getSerializableExtra(NotificationHelper.KEY_VISIBILITY) as Status.Visibility
val spoiler = intent.getStringExtra(NotificationHelper.KEY_SPOILER)
val mentions = intent.getStringArrayExtra(NotificationHelper.KEY_MENTIONS)
val citedText = intent.getStringExtra(NotificationHelper.KEY_CITED_TEXT)
val localAuthorId = intent.getStringExtra(NotificationHelper.KEY_CITED_AUTHOR_LOCAL)
val account = accountManager.getAccountById(senderId)
val notificationManager = NotificationManagerCompat.from(context)
if (intent.action == NotificationHelper.REPLY_ACTION) {
val message = getReplyMessage(intent)
if (account == null) {
Log.w(TAG, "Account \"$senderId\" not found in database. Aborting quick reply!")
val builder = NotificationCompat.Builder(context, NotificationHelper.CHANNEL_MENTION + senderIdentifier)
.setSmallIcon(R.drawable.ic_notify)
.setColor(ContextCompat.getColor(context, (R.color.primary)))
.setGroup(senderFullName)
.setDefaults(0) // So it doesn't ring twice, notify only in Target callback
builder.setContentTitle(context.getString(R.string.error_generic))
builder.setContentText(context.getString(R.string.error_sender_account_gone))
builder.setSubText(senderFullName)
builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
builder.setCategory(NotificationCompat.CATEGORY_SOCIAL)
builder.setOnlyAlertOnce(true)
notificationManager.notify(notificationId, builder.build())
} else {
val text = mentions.joinToString(" ", postfix = " ") { "@$it" } + message.toString()
val sendIntent = SendTootService.sendTootIntent(
context,
text,
spoiler,
visibility,
false,
emptyList(),
emptyList(),
citedStatusId,
null,
null,
null, account, 0)
context.startService(sendIntent)
val builder = NotificationCompat.Builder(context, NotificationHelper.CHANNEL_MENTION + senderIdentifier)
.setSmallIcon(R.drawable.ic_notify)
.setColor(ContextCompat.getColor(context, (R.color.primary)))
.setGroup(senderFullName)
.setDefaults(0) // So it doesn't ring twice, notify only in Target callback
builder.setContentTitle(context.getString(R.string.status_sent))
builder.setContentText(context.getString(R.string.status_sent_long))
builder.setSubText(senderFullName)
builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
builder.setCategory(NotificationCompat.CATEGORY_SOCIAL)
builder.setOnlyAlertOnce(true)
notificationManager.notify(notificationId, builder.build())
}
} else if (intent.action == NotificationHelper.COMPOSE_ACTION) {
context.sendBroadcast(Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS))
notificationManager.cancel(notificationId)
accountManager.setActiveAccount(senderId)
val composeIntent = ComposeActivity.IntentBuilder()
.inReplyToId(citedStatusId)
.replyVisibility(visibility)
.contentWarning(spoiler)
.mentionedUsernames(Arrays.asList(*mentions))
.repyingStatusAuthor(localAuthorId)
.replyingStatusContent(citedText)
.build(context)
context.startActivity(composeIntent)
}
}
private fun getReplyMessage(intent: Intent): CharSequence {
val remoteInput = RemoteInput.getResultsFromIntent(intent)
return remoteInput.getCharSequence(NotificationHelper.KEY_REPLY)
}
}

View file

@ -1,4 +1,5 @@
/* Copyright 2017 Andrew Dawson
/* Copyright 2018 Jeremiasz Nelz <remi6397(a)gmail.com>
* Copyright 2017 Andrew Dawson
*
* This file is a part of Tusky.
*
@ -27,6 +28,8 @@ import android.os.Build;
import android.provider.Settings;
import android.support.annotation.Nullable;
import android.support.v4.app.NotificationCompat;
import android.support.v4.app.NotificationManagerCompat;
import android.support.v4.app.RemoteInput;
import android.support.v4.app.TaskStackBuilder;
import android.support.v4.content.ContextCompat;
import android.util.Log;
@ -34,10 +37,13 @@ import android.util.Log;
import com.keylesspalace.tusky.MainActivity;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.TuskyApplication;
import com.keylesspalace.tusky.ViewThreadActivity;
import com.keylesspalace.tusky.db.AccountEntity;
import com.keylesspalace.tusky.db.AccountManager;
import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.receiver.NotificationClearBroadcastReceiver;
import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver;
import com.keylesspalace.tusky.view.RoundedTransformation;
import com.squareup.picasso.Picasso;
@ -46,10 +52,14 @@ import org.json.JSONException;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
public class NotificationHelper {
private static int notificationId = 0;
/**
* constants used in Intents
*/
@ -57,13 +67,39 @@ public class NotificationHelper {
private static final String TAG = "NotificationHelper";
public static final String REPLY_ACTION = "REPLY_ACTION";
public static final String COMPOSE_ACTION = "COMPOSE_ACTION";
public static final String KEY_REPLY = "KEY_REPLY";
public static final String KEY_SENDER_ACCOUNT_ID = "KEY_SENDER_ACCOUNT_ID";
public static final String KEY_SENDER_ACCOUNT_IDENTIFIER = "KEY_SENDER_ACCOUNT_IDENTIFIER";
public static final String KEY_SENDER_ACCOUNT_FULL_NAME = "KEY_SENDER_ACCOUNT_FULL_NAME";
public static final String KEY_NOTIFICATION_ID = "KEY_NOTIFICATION_ID";
public static final String KEY_CITED_STATUS_ID = "KEY_CITED_STATUS_ID";
public static final String KEY_VISIBILITY = "KEY_VISIBILITY";
public static final String KEY_SPOILER = "KEY_SPOILER";
public static final String KEY_MENTIONS = "KEY_MENTIONS";
public static final String KEY_CITED_TEXT = "KEY_CITED_TEXT";
public static final String KEY_CITED_AUTHOR_LOCAL = "KEY_CITED_AUTHOR_LOCAL";
/**
* notification channels used on Android O+
**/
private static final String CHANNEL_MENTION = "CHANNEL_MENTION";
private static final String CHANNEL_FOLLOW = "CHANNEL_FOLLOW";
private static final String CHANNEL_BOOST = "CHANNEL_BOOST";
private static final String CHANNEL_FAVOURITE = " CHANNEL_FAVOURITE";
public static final String CHANNEL_MENTION = "CHANNEL_MENTION";
public static final String CHANNEL_FOLLOW = "CHANNEL_FOLLOW";
public static final String CHANNEL_BOOST = "CHANNEL_BOOST";
public static final String CHANNEL_FAVOURITE = "CHANNEL_FAVOURITE";
/**
* Takes a given Mastodon notification and either creates a new Android notification or updates
@ -104,31 +140,12 @@ public class NotificationHelper {
account.setActiveNotifications(currentNotifications.toString());
//no need to save account, this will be done in the calling function
// Notification group member
// =========================
final NotificationCompat.Builder builder = newNotification(context, body, account, false);
Intent resultIntent = new Intent(context, MainActivity.class);
resultIntent.putExtra(ACCOUNT_ID, account.getId());
TaskStackBuilder stackBuilder = TaskStackBuilder.create(context);
stackBuilder.addParentStack(MainActivity.class);
stackBuilder.addNextIntent(resultIntent);
PendingIntent resultPendingIntent = stackBuilder.getPendingIntent((int) account.getId(),
PendingIntent.FLAG_UPDATE_CURRENT);
notificationId++;
Intent deleteIntent = new Intent(context, NotificationClearBroadcastReceiver.class);
deleteIntent.putExtra(ACCOUNT_ID, account.getId());
PendingIntent deletePendingIntent = PendingIntent.getBroadcast(context, (int) account.getId(), deleteIntent,
PendingIntent.FLAG_UPDATE_CURRENT);
final NotificationCompat.Builder builder = new NotificationCompat.Builder(context, getChannelId(account, body))
.setSmallIcon(R.drawable.ic_notify)
.setContentIntent(resultPendingIntent)
.setDeleteIntent(deletePendingIntent)
.setColor(ContextCompat.getColor(context, (R.color.primary)))
.setDefaults(0); // So it doesn't ring twice, notify only in Target callback
setupPreferences(account, builder);
if (currentNotifications.length() == 1) {
builder.setContentTitle(titleForType(context, body))
.setContentText(bodyForType(body));
@ -151,28 +168,144 @@ public class NotificationHelper {
builder.setLargeIcon(accountAvatar);
} else {
// Reply to mention action; RemoteInput is available from KitKat Watch, but buttons are available from Nougat
if (body.getType() == Notification.Type.MENTION
&& android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
RemoteInput replyRemoteInput = new RemoteInput.Builder(KEY_REPLY)
.setLabel(context.getString(R.string.label_quick_reply))
.build();
PendingIntent quickReplyPendingIntent = getStatusReplyIntent(REPLY_ACTION, context, body, account);
NotificationCompat.Action quickReplyAction =
new NotificationCompat.Action.Builder(R.drawable.ic_reply_24dp,
context.getString(R.string.action_quick_reply), quickReplyPendingIntent)
.addRemoteInput(replyRemoteInput)
.build();
builder.addAction(quickReplyAction);
PendingIntent composePendingIntent = getStatusReplyIntent(COMPOSE_ACTION, context, body, account);
NotificationCompat.Action composeAction =
new NotificationCompat.Action.Builder(R.drawable.ic_reply_24dp,
context.getString(R.string.action_compose_shortcut), composePendingIntent)
.build();
builder.addAction(composeAction);
}
builder.setSubText(account.getFullName());
builder.setVisibility(NotificationCompat.VISIBILITY_PRIVATE);
builder.setCategory(NotificationCompat.CATEGORY_SOCIAL);
builder.setOnlyAlertOnce(true);
// Summary
// =======
final NotificationCompat.Builder summaryBuilder = newNotification(context, body, account, true);
if (currentNotifications.length() != 1) {
try {
String title = context.getString(R.string.notification_title_summary, currentNotifications.length());
String text = joinNames(context, currentNotifications);
builder.setContentTitle(title)
summaryBuilder.setContentTitle(title)
.setContentText(text);
} catch (JSONException e) {
Log.d(TAG, Log.getStackTraceString(e));
}
}
builder.setSubText(account.getFullName());
summaryBuilder.setSubText(account.getFullName());
summaryBuilder.setVisibility(NotificationCompat.VISIBILITY_PRIVATE);
summaryBuilder.setCategory(NotificationCompat.CATEGORY_SOCIAL);
summaryBuilder.setOnlyAlertOnce(true);
summaryBuilder.setGroupSummary(true);
builder.setVisibility(NotificationCompat.VISIBILITY_PRIVATE);
builder.setCategory(NotificationCompat.CATEGORY_SOCIAL);
builder.setOnlyAlertOnce(true);
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
//noinspection ConstantConditions
notificationManager.notify((int) account.getId(), builder.build());
notificationManager.notify(notificationId, builder.build());
if (currentNotifications.length() == 1) {
notificationManager.notify((int) account.getId(), builder.setGroupSummary(true).build());
} else {
notificationManager.notify((int) account.getId(), summaryBuilder.build());
}
}
private static NotificationCompat.Builder newNotification(Context context, Notification body, AccountEntity account, boolean summary) {
Intent summaryResultIntent = new Intent(context, MainActivity.class);
summaryResultIntent.putExtra(ACCOUNT_ID, account.getId());
TaskStackBuilder summaryStackBuilder = TaskStackBuilder.create(context);
summaryStackBuilder.addParentStack(MainActivity.class);
summaryStackBuilder.addNextIntent(summaryResultIntent);
PendingIntent summaryResultPendingIntent = summaryStackBuilder.getPendingIntent(notificationId,
PendingIntent.FLAG_UPDATE_CURRENT);
// we have to switch account here
Intent eventResultIntent = new Intent(context, ViewThreadActivity.class);
eventResultIntent.putExtra("account", account.getId());
eventResultIntent.putExtra("id", body.getStatus().getId());
TaskStackBuilder eventStackBuilder = TaskStackBuilder.create(context);
eventStackBuilder.addParentStack(ViewThreadActivity.class);
eventStackBuilder.addNextIntent(eventResultIntent);
PendingIntent eventResultPendingIntent = eventStackBuilder.getPendingIntent((int) account.getId(),
PendingIntent.FLAG_UPDATE_CURRENT);
Intent deleteIntent = new Intent(context, NotificationClearBroadcastReceiver.class);
deleteIntent.putExtra(ACCOUNT_ID, account.getId());
PendingIntent deletePendingIntent = PendingIntent.getBroadcast(context, summary ? (int) account.getId() : notificationId, deleteIntent,
PendingIntent.FLAG_UPDATE_CURRENT);
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, getChannelId(account, body))
.setSmallIcon(R.drawable.ic_notify)
.setContentIntent(summary ? summaryResultPendingIntent : eventResultPendingIntent)
.setDeleteIntent(deletePendingIntent)
.setColor(ContextCompat.getColor(context, (R.color.primary)))
.setGroup(account.getAccountId())
.setDefaults(0); // So it doesn't ring twice, notify only in Target callback
setupPreferences(account, builder);
return builder;
}
private static PendingIntent getStatusReplyIntent(String action, Context context, Notification body, AccountEntity account) {
Status status = body.getStatus();
String citedLocalAuthor = status.getAccount().getLocalUsername();
String citedText = status.getContent().toString();
String inReplyToId = status.getId();
Status actionableStatus = status.getActionableStatus();
Status.Visibility replyVisibility = actionableStatus.getVisibility();
String contentWarning = actionableStatus.getSpoilerText();
Status.Mention[] mentions = actionableStatus.getMentions();
List<String> mentionedUsernames = new ArrayList<>();
mentionedUsernames.add(actionableStatus.getAccount().getUsername());
for (Status.Mention mention : mentions) {
mentionedUsernames.add(mention.getUsername());
}
mentionedUsernames.removeAll(Collections.singleton(account.getUsername()));
mentionedUsernames = new ArrayList<>(new LinkedHashSet<>(mentionedUsernames));
Intent replyIntent = new Intent(context, SendStatusBroadcastReceiver.class)
.setAction(action)
.putExtra(KEY_CITED_AUTHOR_LOCAL, citedLocalAuthor)
.putExtra(KEY_CITED_TEXT, citedText)
.putExtra(KEY_SENDER_ACCOUNT_ID, account.getId())
.putExtra(KEY_SENDER_ACCOUNT_IDENTIFIER, account.getIdentifier())
.putExtra(KEY_SENDER_ACCOUNT_FULL_NAME, account.getFullName())
.putExtra(KEY_NOTIFICATION_ID, notificationId)
.putExtra(KEY_CITED_STATUS_ID, inReplyToId)
.putExtra(KEY_VISIBILITY, replyVisibility)
.putExtra(KEY_SPOILER, contentWarning)
.putExtra(KEY_MENTIONS, mentionedUsernames.toArray(new String[0]));
return PendingIntent.getBroadcast(context.getApplicationContext(),
notificationId,
replyIntent,
PendingIntent.FLAG_UPDATE_CURRENT);
}
public static void createNotificationChannelsForAccount(AccountEntity account, Context context) {

View file

@ -63,11 +63,14 @@
<string name="report_comment_hint">Dodatkowe komentarze?</string>
<string name="label_quick_reply">Odpowiedz…</string>
<string name="action_quick_reply">Szybka odpowiedź</string>
<string name="action_reply">Odpowiedz</string>
<string name="action_reblog">Podbij</string>
<string name="action_favourite">Dodaj do ulubionych</string>
<string name="action_more">Więcej</string>
<string name="action_compose">Napisz</string>
<string name="action_compose_shortcut">Odpowiedz</string>
<string name="action_login">Zaloguj się Kontem Mastodon</string>
<string name="action_logout">Wyloguj się</string>
<string name="action_logout_confirm">Czy na pewno chcesz wylogować się z konta %1$s?</string>
@ -318,5 +321,7 @@
<string name="download_image">Pobieranie %1$s…</string>
<string name="action_copy_link">Skopiuj odnośnik</string>
<string name="status_sent">Wysłano!</string>
<string name="status_sent_long">Pomyślnie wysłano odpowiedź.</string>
</resources>

View file

@ -18,6 +18,7 @@
<string name="error_media_upload_sending">The upload failed.</string>
<string name="error_report_too_few_statuses">At least one status must be reported.</string>
<string name="error_invalid_regex">Invalid regular expression</string>
<string name="error_sender_account_gone">Error sending toot.</string>
<string name="title_home">Home</string>
<string name="title_advanced">Advanced</string>
@ -56,6 +57,7 @@
<string name="report_username_format">Report @%s</string>
<string name="report_comment_hint">Additional comments?</string>
<string name="action_quick_reply">Quick Reply</string>
<string name="action_reply">Reply</string>
<string name="action_reblog">Boost</string>
<string name="action_favourite">Favourite</string>
@ -112,6 +114,9 @@
<string name="confirmation_unblocked">User unblocked</string>
<string name="confirmation_unmuted">User unmuted</string>
<string name="status_sent">Sent!</string>
<string name="status_sent_long">Reply sent successfully.</string>
<string name="hint_domain">Which instance?</string>
<string name="hint_compose">What\'s happening?</string>
<string name="hint_content_warning">Content warning</string>
@ -121,6 +126,7 @@
<string name="search_no_results">No results</string>
<string name="label_quick_reply">Reply…</string>
<string name="label_avatar">Avatar</string>
<string name="label_header">Header</string>
@ -295,11 +301,12 @@
<string name="lock_account_label">Lock account</string>
<string name="lock_account_label_description">Requires you to manually approve followers</string>
<string name="compose_save_draft">Save draft?</string>
<string name="send_toot_notification_title">Sending Toot...</string>
<string name="send_toot_notification_title">Sending Toot</string>
<string name="send_toot_notification_error_title">Error sending toot</string>
<string name="send_toot_notification_channel_name">Sending Toots</string>
<string name="send_toot_notification_cancel_title">Sending cancelled</string>
<string name="send_toot_notification_saved_content">A copy of the toot has been saved to your drafts</string>
<string name="action_compose_shortcut">Compose</string>
<string name="error_no_custom_emojis">Your instance %s does not have any custom emojis</string>
<string name="copy_to_clipboard_success">Copied to clipboard</string>