Poll notifications (#1229)

* show poll notifications in the app

* show poll notifications in the app

* allow filtering poll notifications in the poll fragment

* show poll notifications in system notifications
This commit is contained in:
Konrad Pozniak 2019-05-02 19:44:35 +02:00 committed by GitHub
commit e735e4843e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
44 changed files with 1076 additions and 318 deletions

View file

@ -74,7 +74,7 @@ public class TuskyApplication extends Application implements HasActivityInjector
AppDatabase.MIGRATION_5_6, AppDatabase.MIGRATION_6_7, AppDatabase.MIGRATION_7_8,
AppDatabase.MIGRATION_8_9, AppDatabase.MIGRATION_9_10, AppDatabase.MIGRATION_10_11,
AppDatabase.MIGRATION_11_12, AppDatabase.MIGRATION_12_13, AppDatabase.MIGRATION_10_13,
AppDatabase.MIGRATION_13_14, AppDatabase.MIGRATION_14_15)
AppDatabase.MIGRATION_13_14, AppDatabase.MIGRATION_14_15, AppDatabase.MIGRATION_15_16)
.build();
accountManager = new AccountManager(appDatabase);
serviceLocator = new ServiceLocator() {

View file

@ -68,7 +68,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
}
private static final int VIEW_TYPE_MENTION = 0;
private static final int VIEW_TYPE_STATUS = 0;
private static final int VIEW_TYPE_STATUS_NOTIFICATION = 1;
private static final int VIEW_TYPE_FOLLOW = 2;
private static final int VIEW_TYPE_PLACEHOLDER = 3;
@ -77,6 +77,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE};
private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0];
private String accountId;
private StatusActionListener statusListener;
private NotificationActionListener notificationActionListener;
private boolean mediaPreviewEnabled;
@ -84,10 +85,12 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
private BidiFormatter bidiFormatter;
private AdapterDataSource<NotificationViewData> dataSource;
public NotificationsAdapter(AdapterDataSource<NotificationViewData> dataSource,
public NotificationsAdapter(String accountId,
AdapterDataSource<NotificationViewData> dataSource,
StatusActionListener statusListener,
NotificationActionListener notificationActionListener) {
super();
this.accountId = accountId;
this.dataSource = dataSource;
this.statusListener = statusListener;
this.notificationActionListener = notificationActionListener;
@ -101,7 +104,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
LayoutInflater inflater = LayoutInflater.from(parent.getContext());
switch (viewType) {
case VIEW_TYPE_MENTION: {
case VIEW_TYPE_STATUS: {
View view = inflater
.inflate(R.layout.item_status, parent, false);
return new StatusViewHolder(view, useAbsoluteTime);
@ -159,17 +162,20 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
}
NotificationViewData.Concrete concreteNotificaton =
(NotificationViewData.Concrete) notification;
Notification.Type type = concreteNotificaton.getType();
switch (type) {
case MENTION: {
switch (viewHolder.getItemViewType()) {
case VIEW_TYPE_STATUS: {
StatusViewHolder holder = (StatusViewHolder) viewHolder;
StatusViewData.Concrete status = concreteNotificaton.getStatusViewData();
holder.setupWithStatus(status,
statusListener, mediaPreviewEnabled, payloadForHolder);
if(concreteNotificaton.getType() == Notification.Type.POLL) {
holder.setPollInfo(accountId.equals(concreteNotificaton.getAccount().getId()));
} else {
holder.hideStatusInfo();
}
break;
}
case FAVOURITE:
case REBLOG: {
case VIEW_TYPE_STATUS_NOTIFICATION: {
StatusNotificationViewHolder holder = (StatusNotificationViewHolder) viewHolder;
StatusViewData.Concrete statusViewData = concreteNotificaton.getStatusViewData();
if (payloadForHolder == null) {
@ -200,7 +206,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
}
break;
}
case FOLLOW: {
case VIEW_TYPE_FOLLOW: {
if (payloadForHolder == null) {
FollowViewHolder holder = (FollowViewHolder) viewHolder;
holder.setMessage(concreteNotificaton.getAccount(), bidiFormatter);
@ -225,8 +231,9 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
if (notification instanceof NotificationViewData.Concrete) {
NotificationViewData.Concrete concrete = ((NotificationViewData.Concrete) notification);
switch (concrete.getType()) {
case MENTION: {
return VIEW_TYPE_MENTION;
case MENTION:
case POLL: {
return VIEW_TYPE_STATUS;
}
case FAVOURITE:
case REBLOG: {

View file

@ -815,13 +815,13 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
for(int i = 0; i < Status.MAX_POLL_OPTIONS; i++) {
if(i < options.size()) {
long percent = calculatePollPercent(options.get(i).getVotesCount(), poll.getVotesCount());
int percent = options.get(i).getPercent(poll.getVotesCount());
String pollOptionText = pollResults[i].getContext().getString(R.string.poll_option_format, percent, options.get(i).getTitle());
pollResults[i].setText(CustomEmojiHelper.emojifyText(HtmlUtils.fromHtml(pollOptionText), emojis, pollResults[i]));
pollResults[i].setVisibility(View.VISIBLE);
int level = (int) percent * 100;
int level = percent * 100;
pollResults[i].getBackground().setLevel(level);
@ -920,10 +920,4 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
}
private static long calculatePollPercent(int votes, int totalVotes) {
if(votes == 0) {
return 0;
}
return Math.round(votes / (double) totalVotes * 100);
}
}

View file

@ -29,18 +29,19 @@ import com.keylesspalace.tusky.viewdata.StatusViewData;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import at.connyduck.sparkbutton.helpers.Utils;
public class StatusViewHolder extends StatusBaseViewHolder {
private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE};
private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0];
private TextView rebloggedBar;
private TextView statusInfo;
private ToggleButton contentCollapseButton;
StatusViewHolder(View itemView, boolean useAbsoluteTime) {
super(itemView, useAbsoluteTime);
rebloggedBar = itemView.findViewById(R.id.status_reblogged);
statusInfo = itemView.findViewById(R.id.status_info);
contentCollapseButton = itemView.findViewById(R.id.button_toggle_content);
}
@ -71,7 +72,7 @@ public class StatusViewHolder extends StatusBaseViewHolder {
@Override
protected void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener listener,
boolean mediaPreviewEnabled, @Nullable Object payloads) {
if (status == null || payloads==null) {
if (status == null || payloads == null) {
if (status == null) {
showContent(false);
} else {
@ -81,30 +82,39 @@ public class StatusViewHolder extends StatusBaseViewHolder {
String rebloggedByDisplayName = status.getRebloggedByUsername();
if (rebloggedByDisplayName == null) {
hideRebloggedByDisplayName();
hideStatusInfo();
} else {
setRebloggedByDisplayName(rebloggedByDisplayName);
statusInfo.setOnClickListener(v -> listener.onOpenReblog(getAdapterPosition()));
}
rebloggedBar.setOnClickListener(v -> listener.onOpenReblog(getAdapterPosition()));}
}
else{
}
} else {
super.setupWithStatus(status, listener, mediaPreviewEnabled, payloads);
}
}
private void setRebloggedByDisplayName(final String name) {
Context context = rebloggedBar.getContext();
Context context = statusInfo.getContext();
String boostedText = context.getString(R.string.status_boosted_format, name);
rebloggedBar.setText(boostedText);
rebloggedBar.setVisibility(View.VISIBLE);
statusInfo.setText(boostedText);
statusInfo.setVisibility(View.VISIBLE);
}
private void hideRebloggedByDisplayName() {
if (rebloggedBar == null) {
// don't use this on the same ViewHolder as setRebloggedByDisplayName, will cause recycling issues as paddings are changed
void setPollInfo(final boolean ownPoll) {
statusInfo.setText(ownPoll ? R.string.poll_ended_created : R.string.poll_ended_voted);
statusInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_poll_24dp, 0, 0, 0);
statusInfo.setCompoundDrawablePadding(Utils.dpToPx(statusInfo.getContext(), 10));
statusInfo.setPaddingRelative(Utils.dpToPx(statusInfo.getContext(), 28), 0, 0, 0);
statusInfo.setVisibility(View.VISIBLE);
}
void hideStatusInfo() {
if (statusInfo == null) {
return;
}
rebloggedBar.setVisibility(View.GONE);
statusInfo.setVisibility(View.GONE);
}
private void setupCollapsedState(final StatusViewData.Concrete status, final StatusActionListener listener) {

View file

@ -41,6 +41,7 @@ data class AccountEntity(@field:PrimaryKey(autoGenerate = true) var id: Long,
var notificationsFollowed: Boolean = true,
var notificationsReblogged: Boolean = true,
var notificationsFavorited: Boolean = true,
var notificationsPolls: Boolean = true,
var notificationSound: Boolean = true,
var notificationVibration: Boolean = true,
var notificationLight: Boolean = true,

View file

@ -30,7 +30,7 @@ import androidx.annotation.NonNull;
@Database(entities = {TootEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
TimelineAccountEntity.class, ConversationEntity.class
}, version = 15)
}, version = 16)
public abstract class AppDatabase extends RoomDatabase {
public abstract TootDao tootDao();
@ -279,4 +279,11 @@ public abstract class AppDatabase extends RoomDatabase {
}
};
public static final Migration MIGRATION_15_16 = new Migration(15, 16) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsPolls` INTEGER NOT NULL DEFAULT 1");
}
};
}

View file

@ -30,7 +30,8 @@ data class Notification(
MENTION("mention"),
REBLOG("reblog"),
FAVOURITE("favourite"),
FOLLOW("follow");
FOLLOW("follow"),
POLL("poll");
companion object {
@ -42,7 +43,7 @@ data class Notification(
}
return UNKNOWN
}
val asList = listOf(MENTION,REBLOG,FAVOURITE,FOLLOW)
val asList = listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, POLL)
}
override fun toString(): String {

View file

@ -2,6 +2,7 @@ package com.keylesspalace.tusky.entity
import com.google.gson.annotations.SerializedName
import java.util.*
import kotlin.math.roundToInt
data class Poll(
val id: String,
@ -30,4 +31,12 @@ data class Poll(
data class PollOption(
val title: String,
@SerializedName("votes_count") val votesCount: Int
)
) {
fun getPercent(totalVotes: Int): Int {
return if (votesCount == 0) {
0
} else {
(votesCount / totalVotes.toDouble() * 100).roundToInt()
}
}
}

View file

@ -222,7 +222,8 @@ public class NotificationsFragment extends SFragment implements
recyclerView.addItemDecoration(new DividerItemDecoration(context, DividerItemDecoration.VERTICAL));
adapter = new NotificationsAdapter(dataSource, this, this);
adapter = new NotificationsAdapter(accountManager.getActiveAccount().getAccountId(),
dataSource, this, this);
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity());
alwaysShowSensitiveMedia = accountManager.getActiveAccount().getAlwaysShowSensitiveMedia();
boolean mediaPreviewEnabled = accountManager.getActiveAccount().getMediaPreviewEnabled();
@ -652,13 +653,15 @@ public class NotificationsFragment extends SFragment implements
private String getNotificationText(Notification.Type type) {
switch (type) {
case MENTION:
return getString(R.string.filter_mentions);
return getString(R.string.notification_mention_name);
case FAVOURITE:
return getString(R.string.filter_favorites);
return getString(R.string.notification_favourite_name);
case REBLOG:
return getString(R.string.filter_boosts);
return getString(R.string.notification_boost_name);
case FOLLOW:
return getString(R.string.filter_follows);
return getString(R.string.notification_follow_name);
case POLL:
return getString(R.string.notification_poll_name);
default:
return "Unknown";
}

View file

@ -62,6 +62,10 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat(), Preference.O
favoritedPref.isChecked = activeAccount.notificationsFavorited
favoritedPref.onPreferenceChangeListener = this
val pollsPref = requirePreference("notificationFilterPolls") as SwitchPreference
pollsPref.isChecked = activeAccount.notificationsPolls
pollsPref.onPreferenceChangeListener = this
val soundPref = requirePreference("notificationAlertSound") as SwitchPreference
soundPref.isChecked = activeAccount.notificationSound
soundPref.onPreferenceChangeListener = this
@ -94,6 +98,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat(), Preference.O
"notificationFilterFollows" -> activeAccount.notificationsFollowed = newValue as Boolean
"notificationFilterReblogs" -> activeAccount.notificationsReblogged = newValue as Boolean
"notificationFilterFavourites" -> activeAccount.notificationsFavorited = newValue as Boolean
"notificationFilterPolls" -> activeAccount.notificationsPolls = newValue as Boolean
"notificationAlertSound" -> activeAccount.notificationSound = newValue as Boolean
"notificationAlertVibrate" -> activeAccount.notificationVibration = newValue as Boolean
"notificationAlertLight" -> activeAccount.notificationLight = newValue as Boolean

View file

@ -35,6 +35,8 @@ import androidx.core.app.RemoteInput;
import androidx.core.app.TaskStackBuilder;
import androidx.core.content.ContextCompat;
import androidx.core.text.BidiFormatter;
import android.text.TextUtils;
import android.util.Log;
import com.bumptech.glide.Glide;
@ -48,6 +50,8 @@ import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.db.AccountEntity;
import com.keylesspalace.tusky.db.AccountManager;
import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.Poll;
import com.keylesspalace.tusky.entity.PollOption;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.receiver.NotificationClearBroadcastReceiver;
import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver;
@ -105,6 +109,7 @@ public class NotificationHelper {
public static final String CHANNEL_FOLLOW = "CHANNEL_FOLLOW";
public static final String CHANNEL_BOOST = "CHANNEL_BOOST";
public static final String CHANNEL_FAVOURITE = "CHANNEL_FAVOURITE";
public static final String CHANNEL_POLL = "CHANNEL_POLL";
/**
* time in minutes between notification checks
@ -159,12 +164,12 @@ public class NotificationHelper {
notificationId++;
builder.setContentTitle(titleForType(context, body, bidiFormatter))
.setContentText(bodyForType(body));
builder.setContentTitle(titleForType(context, body, bidiFormatter, account))
.setContentText(bodyForType(body, context));
if (body.getType() == Notification.Type.MENTION) {
if (body.getType() == Notification.Type.MENTION || body.getType() == Notification.Type.POLL) {
builder.setStyle(new NotificationCompat.BigTextStyle()
.bigText(bodyForType(body)));
.bigText(bodyForType(body, context)));
}
//load the avatar synchronously
@ -337,21 +342,25 @@ public class NotificationHelper {
CHANNEL_MENTION + account.getIdentifier(),
CHANNEL_FOLLOW + account.getIdentifier(),
CHANNEL_BOOST + account.getIdentifier(),
CHANNEL_FAVOURITE + account.getIdentifier()};
CHANNEL_FAVOURITE + account.getIdentifier(),
CHANNEL_POLL + account.getIdentifier(),
};
int[] channelNames = {
R.string.notification_channel_mention_name,
R.string.notification_channel_follow_name,
R.string.notification_channel_boost_name,
R.string.notification_channel_favourite_name
R.string.notification_mention_name,
R.string.notification_follow_name,
R.string.notification_boost_name,
R.string.notification_favourite_name,
R.string.notification_poll_name
};
int[] channelDescriptions = {
R.string.notification_channel_mention_descriptions,
R.string.notification_channel_follow_description,
R.string.notification_channel_boost_description,
R.string.notification_channel_favourite_description
R.string.notification_mention_descriptions,
R.string.notification_follow_description,
R.string.notification_boost_description,
R.string.notification_favourite_description,
R.string.notification_poll_description
};
List<NotificationChannel> channels = new ArrayList<>(4);
List<NotificationChannel> channels = new ArrayList<>(5);
NotificationChannelGroup channelGroup = new NotificationChannelGroup(account.getIdentifier(), account.getFullName());
@ -472,8 +481,13 @@ public class NotificationHelper {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
String channelId = getChannelId(account, notification);
if(channelId == null) {
// unknown notificationtype
return false;
}
//noinspection ConstantConditions
NotificationChannel channel = notificationManager.getNotificationChannel(getChannelId(account, notification));
NotificationChannel channel = notificationManager.getNotificationChannel(channelId);
return channel.getImportance() > NotificationManager.IMPORTANCE_NONE;
}
@ -486,14 +500,15 @@ public class NotificationHelper {
return account.getNotificationsReblogged();
case FAVOURITE:
return account.getNotificationsFavorited();
case POLL:
return account.getNotificationsPolls();
default:
return false;
}
}
private static String getChannelId(AccountEntity account, Notification notification) {
private static @Nullable String getChannelId(AccountEntity account, Notification notification) {
switch (notification.getType()) {
default:
case MENTION:
return CHANNEL_MENTION + account.getIdentifier();
case FOLLOW:
@ -502,6 +517,10 @@ public class NotificationHelper {
return CHANNEL_BOOST + account.getIdentifier();
case FAVOURITE:
return CHANNEL_FAVOURITE + account.getIdentifier();
case POLL:
return CHANNEL_POLL + account.getIdentifier();
default:
return null;
}
}
@ -554,7 +573,7 @@ public class NotificationHelper {
}
@Nullable
private static String titleForType(Context context, Notification notification, BidiFormatter bidiFormatter) {
private static String titleForType(Context context, Notification notification, BidiFormatter bidiFormatter, AccountEntity account) {
String accountName = bidiFormatter.unicodeWrap(notification.getAccount().getName());
switch (notification.getType()) {
case MENTION:
@ -569,22 +588,43 @@ public class NotificationHelper {
case REBLOG:
return String.format(context.getString(R.string.notification_reblog_format),
accountName);
case POLL:
if(notification.getStatus().getAccount().getId().equals(account.getAccountId())) {
return context.getString(R.string.poll_ended_created);
} else {
return context.getString(R.string.poll_ended_voted);
}
}
return null;
}
private static String bodyForType(Notification notification) {
private static String bodyForType(Notification notification, Context context) {
switch (notification.getType()) {
case FOLLOW:
return "@" + notification.getAccount().getUsername();
case MENTION:
case FAVOURITE:
case REBLOG:
if (notification.getStatus().getSensitive()) {
if (!TextUtils.isEmpty(notification.getStatus().getSpoilerText())) {
return notification.getStatus().getSpoilerText();
} else {
return notification.getStatus().getContent().toString();
}
case POLL:
if (!TextUtils.isEmpty(notification.getStatus().getSpoilerText())) {
return notification.getStatus().getSpoilerText();
} else {
StringBuilder builder = new StringBuilder(notification.getStatus().getContent());
builder.append('\n');
Poll poll = notification.getStatus().getPoll();
for(PollOption option: poll.getOptions()) {
int percent = option.getPercent(poll.getVotesCount());
CharSequence optionText = HtmlUtils.fromHtml(context.getString(R.string.poll_option_format, percent, option.getTitle()));
builder.append(optionText);
builder.append('\n');
}
return builder.toString();
}
}
return null;
}