Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add in-app notification configuration #8375

Open
wants to merge 14 commits into
base: develop
Choose a base branch
from

Conversation

cmrd-senya
Copy link
Member

@cmrd-senya cmrd-senya commented Jul 21, 2022

fix #7686

This PR implements in-app notification configuration feature - ability to turn off in-app notifications per event similar to email. See the comments for screenshots.

Original comment (outdated)

This PR is rather trivial... I don't know if we are okay with moving things in tiny steps. Tell me if we're not.

Our database schema for user notification preferences needs to be extended if we want to support another type of notifications, i.e. in app notifications.

Currently the presence of the user preference record means that the email notification is disabled while absence means it is enabled.

If we want to add in-app notifications and if we want to make them configurable independently from email notifications then we need to support 4 states, not just 2 like now.

Therefore I added 2 new columns -- email_enabled and in_app_enabled.

The default states of the columns correspond current behavior. This is done for backward compatibility. In app notifications are enabled because they are always enabled currently.

Email notifications are disabled because the presence of the record means they are disabled.

Having null: false and the default value set will "back fill" the existing records to correct values in the databases during the migration or lazily depending on the DB type and version.

In the next step (which I imagine to be a separate PR) we can update the existing code to check for email_enabled field value instead of the presence of the record. This will prepare us for the scenarios where the record exists but email_enabled is true.

We can also rename email_type column to notification_type sometime later.

@cmrd-senya cmrd-senya marked this pull request as ready for review July 21, 2022 19:54
Copy link
Member

@SuperTux88 SuperTux88 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me so far. But I'm not really sure if it's worth already merging single commits that don't do anything useful yet (well, they do something useful for the future, but merging them now doesn't have any benefit). It only fills the commit log with merge commits and later makes it more difficult to find connected commits (so other steps for the same feature, when they were spread out as single merges).

What I'm absolutely fine with (or even prefer) is, doing small steps in a PR, so having smaller commits with a clear commit message, to split a branch into multiple steps, that makes the reviewing a lot easier. And you are also welcome to ask for feedback or a review whenever you want, even when the feature isn't completely done yet.

There are some scenarios when it's also worth to already merge a PR before the feature is completely done, for example:

  • when it already does something useful for the user, so users can profit from it and don't need to wait for the full feature
  • when there are some cases that would be useful to have them merged so another feature can also build on the changes
  • or when the PR becomes bigger and there is risk of having conflicts with other branches (so similar to above, if other features are working in the same area)
  • or maybe something else that doesn't come to my mind at the moment, this list isn't complete

But none of this is the case for this PR yet, so I would just continue pushing your next steps to this PR and ask for feedback whenever you want.

Small question regarding your plan for the next steps: are you planning to insert a true/true entry for every notification type and user, or do you want to still only inserting stuff when the user changes the defaults, but assume true/true if there is no entry?

@cmrd-senya
Copy link
Member Author

Small question regarding your plan for the next steps: are you planning to insert a true/true entry for every notification type and user, or do you want to still only inserting stuff when the user changes the defaults, but assume true/true if there is no entry?

I was thinking about the latter - assuming true/true when there is no entry

@SuperTux88
Copy link
Member

Yes, I also prefer it that way I think. Keeping the table full of true/true for all users would be more complicated (we would also need to insert new entries every time we add a new notification type, for all users), and I think using the table only to store the state for users who changed away from the default makes more sense. And it's also how the table is used at the moment, because we already have entries for all users who changed away from the default 👍

@cmrd-senya cmrd-senya marked this pull request as draft July 21, 2022 21:25
app/models/user.rb Outdated Show resolved Hide resolved
@cmrd-senya
Copy link
Member Author

image

Okay, we're almost there. It's left to update the specs.

@SuperTux88
Copy link
Member

Maybe add an explanation or a header at the top of the two columns to explain what they mean ... because at the moment somebody could also understand it as they receive a private message instead of a mail? 🤔

@cmrd-senya
Copy link
Member Author

Maybe add an explanation or a header at the top of the two columns to explain what they mean ... because at the moment somebody could also understand it as they receive a private message instead of a mail? thinking

Yes, I thought about that too. I can add a header. Maybe also some kind of tooltip could be helpful?

@SuperTux88
Copy link
Member

Yes, a tooltip would also work, but tooltips only work on desktop, not on mobile with touch as you can't hover over things.

@@ -122,61 +122,180 @@
.row
.col-md-12
%h3
= t(".receive_email_notifications")
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we clean up existing translations for this string? Or it will be done automatically when we sync with the translation platform?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You only need to update the English translations, other languages are managed by webtranslateit.

@cmrd-senya
Copy link
Member Author

Added hearer
image

@cmrd-senya cmrd-senya changed the title Add schema changes to user preferences to support in notifications Add in-app notification configuration Jul 23, 2022
@cmrd-senya cmrd-senya marked this pull request as ready for review July 23, 2022 23:48
@cmrd-senya
Copy link
Member Author

@SuperTux88 I think it can be reviewed now. Let me know if I should add some additional specs.

Copy link
Member

@SuperTux88 SuperTux88 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I also mentioned in some comments, but TL;DR: I would also refactor how this whole thing works some more and also change some naming, as everything is touched now anyway, so I think that's a good moment to clean some stuff up that was already confusing before and now is even more confusing.

  • rename user_preferences/email_preferences to notification_settings
  • also rename/remove some mentions of email in variables where it now also means in-app notifications
  • fix that the booleans are the wrong way around when submitting the form, true should always mean enabled, false should always mean disabled.

Again, most of this isn't because of stuff you changed now, it was already confusing in the past, I think it's just a good opportunity to make it less confusing as most of the code is touched now anyway.

Also, the way you implemented it now, it doesn't send mails anymore if you disable in-app notifications. But emails should still be sent if they are enabled. So you also need to refactor how this works, so you are able to send emails without persisting an in-app notification to the database.

But overall I think this goes into the right direction 👍

Comment on lines +183 to +185
UserPreference::VALID_EMAIL_TYPES.each do |type|
user_preferences.find_or_create_by(email_type: type).update(email_enabled: false)
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't even know why this is here (looks like something very old), and it doesn't make a lot of sense to me. Maybe 10 years ago you could only disable all mails or enable all mails, and this was some sort of migration code ... but nothing in the UI reads the disable_mail flag, so setting all types to disabled silently in the backend when changing the settings in the frontend doesn't make a lot of sense. We should just set what the user did in the frontend, so I would just drop these 3 lines.

Suggested change
UserPreference::VALID_EMAIL_TYPES.each do |type|
user_preferences.find_or_create_by(email_type: type).update(email_enabled: false)
end

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it seemed to me as well that it is not being used. I'll remove it.

Comment on lines 355 to 358
email_enabled = (disable_mail == false) &&
NotificationSettingsService.new(self).email_enabled?(pref)

job.perform_async(*args) if email_enabled
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would probably write this as guard clause:

Suggested change
email_enabled = (disable_mail == false) &&
NotificationSettingsService.new(self).email_enabled?(pref)
job.perform_async(*args) if email_enabled
return if disable_mail || NotificationSettingsService.new(self).email_disabled?(pref)
job.perform_async(*args)

But either way, (disable_mail == false) doesn't make much sense, just use !disable_mail


attr_reader :user

delegate :user_preferences, to: :user
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

user_preferences is just used once here ... so I would just use user.user_preferences that one time instead.

Comment on lines 254 to 256
expect {
put :update, params: par
}.to change(@user.user_preferences, :count).by(1)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this expect isn't needed anymore, the new check below is enough as it represents the new behaviour.

Suggested change
expect {
put :update, params: par
}.to change(@user.user_preferences, :count).by(1)
put :update, params: par

The same goes for lets the user get mail again below

Comment on lines 492 to +494
expect {
alice.update_user_preferences({"mentioned" => false})
}.to change(alice.user_preferences, :count).by(@pref_count - 1)
alice.update_user_preferences({"mentioned" => "false"})
}.to change(alice.user_preferences, :count).by(@pref_count)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of checking the rows count, a check for if the correct attributes were set would probably be more useful.

Also this should set settings for both email and in_app.

Comment on lines +5 to +8
change_table :user_preferences, bulk: true do |t|
t.boolean :email_enabled, null: false, default: false
t.boolean :in_app_enabled, null: false, default: true
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would like to suggest some general changes regarding this whole thing, as this is touched now anyways. To me the naming of this table never made sense, as it's specific a table for notifications and not about "user preferences" ... so I would suggest renaming it to something that explains what this is, for example notification_settings (as you named the service NotificationSettingsService), and then also rename the variables and everything to something regarding notifications. I think that will make the code overall more readable because I was always confused about this.

Also, after thinking about it a bit more, I don't think the _enabled suffix for the new columns is needed, I think it's self-explained anyway, but if you want to keep it, that's fine for me too.

Also, when you are at it, can you rename the existing email_type column to just type, since it's not about emails anymore. I also think that will make this more readable for the future.

@@ -137,7 +137,7 @@ def user_params
:post_default_public,
:exported_photos_file,
:export,
email_preferences: UserPreference::VALID_EMAIL_TYPES.map(&:to_sym)
email_preferences: UserPreference::VALID_EMAIL_TYPES.to_h {|type| [type.to_sym, %i[mail in_app]] }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As this is not about emails only anymore, renaming everything that is now email_preferences (or email_prefs) to notification_settings would make more sense to me.

Comment on lines +191 to +192
email_enabled = pref_hash[key]["mail"] == "false"
in_app_enabled = pref_hash[key]["in_app"] == "false"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please also remove this negation here. It should be true if it's enabled everywhere and false if it's disabled, but at the moment when submitting the form it's exactly the wrong way around and that's extremely confusing. I also already found this negation confusing in the past, but it at least kinda made sense because how the table worked in the past.

Comment on lines +453 to +468
expect {
alice.update_user_preferences(
"mentioned" => {
"mail" => "false",
"in_app" => "false"
},
"contacts_birthday" => {
"mail" => "false",
"in_app" => "false"
},
"private_message" => {
"mail" => "true",
"in_app" => "false"
}
)
}.to change(alice.user_preferences, :count).by(3)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the number of changed rows is important here, as the checks below already verify the changes that are really important here, so I would remove the expect block around here.

But maybe test different combinations of email and in_app enabled/disabled.

}

[type="checkbox"]:checked ~ i {
color: $black;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs some override for the dark theme in _color_theme_override_dark.scss for the dark theme ($white?) and maybe also for the color when unchecked.

@cmrd-senya
Copy link
Member Author

cmrd-senya commented Jul 24, 2022

Thanks for the feedback, indeed, I didn't realize that sending email notifications depends on the Notification record presence. I can see how to fix that but it'll require a bit more refactoring. I'll work on it.

@cmrd-senya
Copy link
Member Author

@SuperTux88 I'm thinking about moving these self.notify methods out of the models:

def self.notify(comment, _recipient_user_ids)

I'd like to put the code to the "service" layer, possibly creating a new service object for it. There is already NotificationService but it will be too much to put everything there so I'd rather create another one. Could be something like NotifyUserService or SendNotificationService. I like service names to be more like verbs than like nouns. NotificationService#notify method could also be moved to that new service too. Basically NotificationService is doing a few different things right now which don't need to be kept in one class, I think that a service should implement one action, not a few different like index, create and update at once.

How does it sound? Would it be okay?

@SuperTux88
Copy link
Member

Sounds good to me so far. I don't mind too much if one service does multiple things about notifications, or if there are different services for it. But if it otherwise would become too big, it's probably better to split them up so it's clearer to read what belong together and also maybe easier to test. As the current notify method doesn't add a lot to the NotificationService at the moment, so it was fine to have it in there, but if it now gets more logic to differ between mail and in-app, it probably makes sense to split stuff up a bit. But I don't know what exactly is needed yet, but I think you'll figure that out as you go 👍

Notifications::MentionedInComment
end

def self.notify(mentionable, recipient_user_ids)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Metrics/AbcSize: Assignment Branch Condition size for notify is too high. [<6, 27, 6> 28.3/20]

@cmrd-senya
Copy link
Member Author

cmrd-senya commented Aug 18, 2022

@SuperTux88 so after some thought I went with the following solution: I moved all self.notify methods from the notification models to respective services which I put to app/services/notifications/*_service.rb (commit). Then I changed the self.notify methods in the services the way that the email sending code wasn't dependent on existing notification record instance (commit).

How do you like that?

I haven't updated the specs yet, thought better to discuss if the refactoring looks okay.

I'm also thinking of moving User#mail method somewhere to the services from the user model.


module Notifications
class AlsoCommentedService
def self.notify(comment, _)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Metrics/AbcSize: Assignment Branch Condition size for notify is too high. [<4, 20, 2> 20.49/20]

Notifications::MentionedInComment
end

def self.notify(mentionable, recipient_user_ids)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Metrics/AbcSize: Assignment Branch Condition size for notify is too high. [<6, 28, 6> 29.26/20]
  • Metrics/MethodLength: Method has too many lines. [25/20]

Copy link
Member

@SuperTux88 SuperTux88 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think overall this goes into the right direction, but there are still some issues with handling in-app and mails differently and some places still relying on the notification objects and some other minor comments.

Comment on lines +62 to +65
email_enabled = (user.disable_mail == false) &&
NotificationSettingsService.new(user).email_enabled?(pref)

job.perform_async(*args) if email_enabled
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still would write this as guard clause and the == false doesn't make a lot of sense (see old comment)

Suggested change
email_enabled = (user.disable_mail == false) &&
NotificationSettingsService.new(user).email_enabled?(pref)
job.perform_async(*args) if email_enabled
return if user.disable_mail || NotificationSettingsService.new(user).email_disabled?(pref)
job.perform_async(*args)

@@ -2,13 +2,17 @@

class NotificationService
NOTIFICATION_TYPES = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe rename that NOTIFICATION_SERVICES now?

Comment on lines +16 to +21
NotificationService.new(recipient).mail(
Workers::Mail::AlsoCommented,
recipient.id,
actor.id,
comment.id
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a bit duplicate that the recipient is already a parameter for the service, and then the recipient.id is added again as parameter for the mail method. I think we can remove the recipient.id parameter here for all notification services, and then set it for all in the NotificationService#mail:

job.perform_async(user.id, *args)

recipient_ids = commentable.participants.local.where.not(id: [commentable.author_id, actor.id]).pluck(:owner_id)

User.where(id: recipient_ids).find_each do |recipient|
next if recipient.is_shareable_hidden?(commentable) || mention_notification_exists?(comment, recipient.person)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mention_notification_exists?(comment, recipient.person) still relies on the notification object existing. So if somebody has disabled the in-app notifications for mentions (but enabled mails for both mentions and comments), they would receive two mails for the same comment, as this would trigger a second notification, as the mention notification object wasn't created.

I don't know what's the best solution for this. The mentions notification already handles mails being disabled for mentions, and then sends a normal commented mail. But when we would for example check here, if there is a mention in the comment, then we wouldn't send duplicate mails (but still one mail, as that's already handled in the mentions notification service ... but we wouldn't send an in-app notification if that's disabled for mentions (unless we also move that logic to the mentions notification service as well?) But then we have even more logic in the mentions notification service, that's kinda duplicated? 🤷‍♂️ Maybe you have an idea how to solve that the best?

@@ -53,6 +45,11 @@ def self.create_notification(recipient, target, actor)
end

private_class_method def self.suppress_notification?(recipient, actor)
recipient.blocks.where(person: actor).exists?
return true if recipient.blocks.exists?(person: actor)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This now isn't called anymore for mails, so it now always sends mails, even if the actor is blocked/ignored. But as you extended this now to also check if in-app notifications are enabled, it can't just be called for mails. So I think the best would be to split this up again, so this can be called both for mails and for in-app notifications, and then only check for in-app notifications enabled/disabled in Notification.create_notification and Notification.concatenate_or_create?

Comment on lines +18 to +19
Notifications::PrivateMessage
.new(recipient: recipient)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't needed, this was only a workaround because the mail needed the object, but it's never persisted.

Comment on lines +9 to +31
def self.notify(mentionable, recipient_user_ids)
actor = mentionable.author
relevant_mentions = filter_mentions(
mentionable.mentions.local.where.not(person: actor),
mentionable,
recipient_user_ids
)

relevant_mentions.each do |mention|
recipient = mention.person.owner
next if model.exists?(recipient: recipient, target: mention)

model
.create_notification(recipient, mention, actor)

NotificationService.new(recipient).mail(
Workers::Mail::Mentioned,
recipient.id,
actor.id,
mention.id
)
end
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was used for both mentions in post and mentions in comments before, but the code now got duplicated. Maybe this can be extracted to an abstract mentioned notification service (needs to add a mail_job method again instead of hardcoding the job, but the model is already called from a method).


relevant_mentions.each do |mention|
recipient = mention.person.owner
next if model.exists?(recipient: recipient, target: mention)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This relies on the notification entity existing, which isn't the case if in-app notifications are disabled. This was a workaround for when posts were received twice, no duplicate notifications were created (#7721). I don't remember all the details and reasons, but Diaspora::Federation::Receive.status_message still processes a received post even, if it already exists, and because of that it also calls ReceiveLocal again.

I don't know/remember why a post should be received again if it already exists, maybe that was a workaround for when a post was created incomplete because of the scenario described here where some data for posts was missing, and receiving them again properly through the federation, would add this missing data. But if that gets fixed so it fetches the posts using federation code, it doesn't need to be received a second time, as it was already complete the first time. That would then also solve the problem of duplicate notifications, without needing the check for them here (as I don't know how otherwise this should be solved for not sending duplicate mails).

Copy link
Member Author

@cmrd-senya cmrd-senya Sep 4, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@SuperTux88 is it enough to do the proper fetching using federation code? It seems quite easy.

Commit: ecb3923

Didn't look at the tests yet.

Copy link
Member Author

@cmrd-senya cmrd-senya Sep 4, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Probably worth adding exception handling too) Updated

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it enough to do the proper fetching using federation code? It seems quite easy.

Yes, that's theoretically all that's needed, but the type should be the lowercase, snake_case version of the entity name (doc).

But you can even improve that a little bit more by just using :post as type (as it could also be a reshare) and you can remove the check_type in validate, so that way we can even fetch reshares (you could also use the type from the json, but then you would need to convert that to the snake_case version, so just always use :post works and is easier) :)

But yes, basically that, it's fairly easy 👍

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And the exception should be DiasporaFederation::Federation::Fetcher::NotFetchable.

@cmrd-senya
Copy link
Member Author

Thanks for the detailed review @SuperTux88! Gonna work through this 👍

@denschub denschub removed their request for review September 7, 2022 16:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

User setting: enable/disable notifications in-app as well as by email
3 participants