In-App Notifications
In-app notifications is a system to schedule notifications for subsets of our user base with server-side definitions. As such, we don’t have to ship all possible messaging in advance when building the application. However, we still include some high value notifications in the built application to ensure they are shown to as many users as possible.
Location
The in-app notification system is fairly self-contained in mail/components/inappnotifications
. The only exception are strings for localization, styles, as well as branding specific data, prefs and the starting entrypoints in MailGlue
and messenger.js
.
The cache is stored in the scheduled-notifications
folder within the profile.
Architecture
The client code is generally split into two concerns: a back-end that manages the data and picking out the notification that should currently be shown, as well as handling the special notification types that open a browser or in-application tab directly.
The UI code is almost entirely implemented in custom elements, which are injected from messenger.js.
The code for the server service used to serve the notification data is available at https://github.com/thunderbird/thunderbird-notifications/. It also contains a schema describing the shape of the notification data.
The back-end is initialized from the InAppNotifications
module, which glues all the features of the back-end together.
The custom elements start with an in-app-notification-manager
element that connects to the events of the NotificationManager
exposed through the InAppNotifications
module from the back-end and then creates and removes notifications as needed. It does not have any interaction logic itself, instead it tells the NotificationManager
about the user interaction, which might then in turn tell the UI to hide a notification.
Selecting which notification to display
The NotificationManager
is what ultimately selects the notification to display, after the raw data was filtered by NotificationFilter
. It chooses the notification with the highest severity (so lowest number). It tries to avoid switching notification if there is one already visible, only displacing the current notification if there is a notification with a higher severity to display instead. Lastly the notification manager handles the case where the notification expires (end_at
transitioning to the past), requesting new notifications when that happens. It also requests new notifications to display when a notification is dismissed. It generally tries to wait a bit before showing the next notification.
InAppNotifications
asks NotificationManager
to recalculate the current notification whenever the raw data is updated, or when any new notification becomes available (its start_at
transitioning to the past). It also handles the requests for current notifications from the NotificationManager
by giving it the currently cached list of available notifications.
Updating
The local cache of notifications is regularly refreshed against a server (specified by an URL that is formatted using the URL formatter). If at startup of the system the server can’t be reached and there’s either no cache or the cache is older than the application build, it falls back to a set of notifications that were included at build time.
There is a cache timestamp, with the lifetime controlled by a pref. If the cache is recent enough, the network is not contacted.
Cache
The notification data provided by the server is cached locally (notifications
), in addition the cache also contains seeds for the set of notifications currently returned by the server (seeds
), a list of notification IDs that should no longer be shown because the user interacted with them (interactedWith
) - also limited to notifications that the server currently returns - and lastly the timestamp of when the cached data from the server was last updated (lastUpdate
).
See also the displayed_notifications section for some more usage info related to the list of notifications that were shown.
The seeds are stored in an object, keyed by notification ID with the value being the seed this profile rolled for that notification.
Data format/contents
The schema for the notifiation data is maintained at https://github.com/thunderbird/thunderbird-notifications/.
Text fields
The user-facing text fields are generally expected to only contain plain text. The UI might limit how much of the text is visible by default.
URL
Only URL values that use the https
protocol are allowed, otherwise the notification is never shown. The URL is formatted using the URL formatter.
Types
There are two kinds of notification types: ones that show an actual notification within the application and ones that trigger an action directly when “shown”.
Type | Behavior | Used fields | Telemetry events |
---|---|---|---|
donation_browser |
Opens tab in the default system browser | URL |
interaction |
donation_tab |
Opens tab within the application | URL |
interaction |
donation |
Shows a dismissable notification with illustrations related to our typical fundraising look. | title , description , CTA /URL |
shown , interaction , closed , dismissed |
blog |
Shows a dismissable notification with a simple style and a "circle-question" icon. | title , description , CTA /URL |
shown , interaction , closed , dismissed |
message |
Shows a dismissable notification with a simple style and a "circle-error" icon. | title , description , CTA /URL |
shown , interaction , closed , dismissed |
security |
Shows a dismissable notification with a simple style and a "warning" icon. | title , description , CTA /URL |
shown , interaction , closed , dismissed |
Notably the shown
telemetry event is triggered every time a notification is shown, which can be multiple times per profile, since it will be shown every time the application is launched until any of the other three events occurs.
Targeting/filtering
Date range
Notifications will be only shown in the timespan between start_at
and end_at
. This means a notifications will be shown at start_at
at the earliest, and hidden by end_at
, even if the user never interacted with it - or never got to see it. The date-time string is parsed using Date.parse
, so the format should be one supported by it (like ISO 8601).
percent_chance
The value determines how many percent of the user population should see the notification. This is implemented by rolling a seed between 0 and 100 (inclusive) per notification stored in the profile. That way, we always make the same decision for the same notification, but we don’t end up showing all notifications to some users and much fewer notification to another set of users.
Removes that amount of people from the remaining pool if used in combination with displayed_notifications
. So 33%/33%/33% is actually declared as 33%/50%/100% with decreasing severity.
exclude/include
The exclude
and include
keys allow us to target specific configurations of Thunderbird. They are both arrays of configurations. To put it differently, the objects in the array are ORed against each other, while the keys in the objects are ANDed - so like a DNF.
When the exclude
or include
key are null
or omitted the notification is displayed without any checks in relation to the conditions those keys could check. An empty array for exclude
will also behave like that, however an empty array for include
will lead to the notification never being shown.
Profile properties
There are two kinds of keys that we target in the profile, single values and lists of values. Both of them have arrays in the targeting profile, but the arrays behave differently.
Single values are locales
, versions
, channels
and operating systems
- there is only one possible active value for all of them. So the values in the array of the targeting profile are ORed against each other, if any of them is the current value the profile matches.
displayed_notifications
, pref_true
and pref_false
compare against lists of values. So all of the items listed in the targeting profile have to be true for the profile to match.
If any key is missing or null
it will not affect the filtering result. Meanwhile an empty array will behave differently for the single values, leading to the profile always matching, while it behaves like null
for the properties for lists of values.
displayed_notifications
Assert that the IDs listed have been displayed in this profile. The IDs have to still be present as notifications in the full list returned by the server since they were shown. Else the application forgets that it has shown the notification. Notably, those “past” notifications no longer need to have any useful information returned by the server, apart from their ID and they would still need to be valid according to the schema, so they should probably retain their end_at
date. But things like targeting and texts can be shortened to the minimum allowed value.
pref_true/pref_false
The pref_true
key allows targeting preferences that are set to true
. If there is no default value shipped with the application, unset values are treated as false
. pref_false
is almost the opposite, except that it treats unset prefs as being true
.
This means an unset pref can be targeted with the following:
{
"id": "test-notification",
[...],
"targeting": {
"exclude": [
{
"pref_true": ["example.unset.pref"],
},
{
"pref_false": ["example.unset.pref"],
},
]
}
}
Preferences
mail.inappnotifications.bypass-filtering
This preference disables all filtering logic (including if a notification has been shown before and was interacted with), leading to the most severe notification provided by the server being shown. This applies the next time the active notification is updated, so either when the currently shown notification is closed, a new notification could become active or when the application is restarted.
mail.inappnotifications.url
The URL is specified in the branding specific preferences, so its value varies depending on the version of Thunderbird. The value is the URL used to update notifications from the server.
mail.inappnotifications.refreshInterval
The refresh interval controls how often we try to get new notifications from the server. It is a value in seconds. The fetch does not bypass HTTP caching, so this interval might not be well aligned with HTTP caching.