diff --git a/docs/THIRDPARTY b/docs/THIRDPARTY index a0a18cf48e..6e7827bd7f 100644 --- a/docs/THIRDPARTY +++ b/docs/THIRDPARTY @@ -77,7 +77,12 @@ Copyright: 2003-2006 Thom May 2018 Kandra Labs, Inc., and contributors License: BSD-3-Clause -Files: static/audio/zulip.* +Files: static/audio/notification_sounds/ding.* +Copyright: 2017 InspectorJ +License: CC-BY-3.0 +Comment: From https://freesound.org/s/411089/. + +Files: static/audio/notification_sounds/zulip.* Copyright: 2011 Vidsyn License: CC-0-1.0 diff --git a/frontend_tests/casper_tests/06-settings.js b/frontend_tests/casper_tests/06-settings.js index 8ff1e586aa..5e8a539bb7 100644 --- a/frontend_tests/casper_tests/06-settings.js +++ b/frontend_tests/casper_tests/06-settings.js @@ -338,6 +338,28 @@ casper.waitUntilVisible('#language-settings-status a', function () { casper.test.assertSelectorHasText('#language-settings-status', 'Gespeichert. Bitte lade die Seite neu um die Ă„nderungen zu aktivieren.'); }); +casper.then(function () { + casper.waitUntilVisible('[data-section="notifications"]', function () { + casper.test.info('Testing disabled/enabled behavior for Notification sound'); + casper.click('[data-section="notifications"]'); + }); +}); + +casper.then(function () { + // At the beginning, `#enable_sounds` will be on and `#enable_stream_sounds` + // will be off by default. + casper.test.assertVisible("#notification_sound:enabled", "Notification sound selector is enabled"); + + casper.click('#enable_stream_sounds'); + casper.test.assertVisible("#notification_sound:enabled", "Notification sound selector is enabled"); + + casper.click('#enable_sounds'); + casper.test.assertVisible("#notification_sound:enabled", "Notification sound selector is enabled"); + + casper.click('#enable_stream_sounds'); + casper.test.assertVisible("#notification_sound:disabled", "Notification sound selector is disabled"); +}); + casper.thenOpen("http://zulip.zulipdev.com:9981/"); // TODO: test the "Declare Zulip Bankruptcy option" diff --git a/static/audio/notification_sounds/ding.mp3 b/static/audio/notification_sounds/ding.mp3 new file mode 100644 index 0000000000..f623c2b196 Binary files /dev/null and b/static/audio/notification_sounds/ding.mp3 differ diff --git a/static/audio/notification_sounds/ding.ogg b/static/audio/notification_sounds/ding.ogg new file mode 100644 index 0000000000..53d1c62ff3 Binary files /dev/null and b/static/audio/notification_sounds/ding.ogg differ diff --git a/static/audio/notification_sounds/zulip.mp3 b/static/audio/notification_sounds/zulip.mp3 new file mode 100644 index 0000000000..c81cb0d654 Binary files /dev/null and b/static/audio/notification_sounds/zulip.mp3 differ diff --git a/static/audio/notification_sounds/zulip.ogg b/static/audio/notification_sounds/zulip.ogg new file mode 100644 index 0000000000..e999834a84 Binary files /dev/null and b/static/audio/notification_sounds/zulip.ogg differ diff --git a/static/audio/zulip.mp3 b/static/audio/zulip.mp3 deleted file mode 100644 index 1f31a459f0..0000000000 Binary files a/static/audio/zulip.mp3 and /dev/null differ diff --git a/static/js/notifications.js b/static/js/notifications.js index c9c739826e..47e570735f 100644 --- a/static/js/notifications.js +++ b/static/js/notifications.js @@ -64,6 +64,14 @@ exports.get_notifications = function () { return notice_memory; }; +function get_audio_file_path(audio_element, audio_file_without_extension) { + if (audio_element.canPlayType('audio/ogg; codecs="vorbis"')) { + return audio_file_without_extension + ".ogg"; + } + + return audio_file_without_extension + ".mp3"; +} + exports.initialize = function () { $(window).focus(function () { window_has_focus = true; @@ -86,21 +94,37 @@ exports.initialize = function () { supports_sound = false; } else { supports_sound = true; + $("#notifications-area").append(audio); + audio.append($("").attr("loop", "yes")); + var source = $("#notifications-area audio source"); + if (audio[0].canPlayType('audio/ogg; codecs="vorbis"')) { - audio.append( - $("").attr("type", "audio/ogg") - .attr("loop", "yes") - .attr("src", "/static/audio/zulip.ogg")); + source.attr("type", "audio/ogg"); } else { - audio.append( - $("").attr("type", "audio/mpeg") - .attr("loop", "yes") - .attr("src", "/static/audio/zulip.mp3")); + source.attr("type", "audio/mpeg"); } + + var audio_file_without_extension + = "/static/audio/notification_sounds/" + page_params.notification_sound; + source.attr("src", get_audio_file_path(audio[0], audio_file_without_extension)); } }; +function update_notification_sound_source() { + // Simplified version of the source creation in `exports.initialize`, for + // updating the source instead of creating it for the first time. + var audio = $("#notifications-area audio"); + var source = $("#notifications-area audio source"); + var audio_file_without_extension + = "/static/audio/notification_sounds/" + page_params.notification_sound; + source.attr("src", get_audio_file_path(audio[0], audio_file_without_extension)); + + // Load it so that it is ready to be played; without this the old sound + // is played. + $("#notifications-area").find("audio")[0].load(); +} + exports.permission_state = function () { if (window.Notification === undefined) { // act like notifications are blocked if they do not have access to @@ -633,6 +657,11 @@ exports.handle_global_notification_updates = function (notification_name, settin if (settings_notifications.notification_settings.indexOf(notification_name) !== -1) { page_params[notification_name] = setting; } + + if (notification_name === "notification_sound") { + // Change the sound source with the new page `notification_sound`. + update_notification_sound_source(); + } }; return exports; diff --git a/static/js/settings.js b/static/js/settings.js index 263c7c0896..dbc7788990 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -126,6 +126,7 @@ exports.build_page = function () { var rendered_settings_tab = templates.render('settings_tab', { full_name: people.my_full_name(), page_params: page_params, + enable_sound_select: page_params.enable_sounds || page_params.enable_stream_sounds, zuliprc: 'zuliprc', botserverrc: 'botserverrc', timezones: moment.tz.names(), diff --git a/static/js/settings_notifications.js b/static/js/settings_notifications.js index df398d4282..a5a6b43e64 100644 --- a/static/js/settings_notifications.js +++ b/static/js/settings_notifications.js @@ -19,6 +19,7 @@ var pm_mention_notification_settings = [ ]; var other_notification_settings = [ + "notification_sound", "enable_digest_emails", "enable_login_emails", "realm_name_in_notifications", @@ -66,7 +67,16 @@ exports.set_up = function () { _.each(other_notification_settings, function (setting) { $("#" + setting).change(function () { - change_notification_setting(setting, $(this).prop('checked'), + var value; + + if (setting === "notification_sound") { + // `notification_sound` is not a boolean. + value = $(this).val(); + } else { + value = $(this).prop('checked'); + } + + change_notification_setting(setting, value, "#other-notify-settings-status"); }); }); @@ -83,6 +93,23 @@ exports.set_up = function () { }); }); + $("#play_notification_sound").click(function () { + $("#notifications-area").find("audio")[0].play(); + }); + + var notification_sound_dropdown = $("#notification_sound"); + notification_sound_dropdown.val(page_params.notification_sound); + + $("#enable_sounds, #enable_stream_sounds").change(function () { + if ($("#enable_stream_sounds").prop("checked") || $("#enable_sounds").prop("checked")) { + notification_sound_dropdown.prop("disabled", false); + notification_sound_dropdown.parent().removeClass("control-label-disabled"); + } else { + notification_sound_dropdown.prop("disabled", true); + notification_sound_dropdown.parent().addClass("control-label-disabled"); + } + }); + $("#enable_desktop_notifications").change(function () { settings_ui.disable_sub_setting_onchange(this.checked, "pm_content_in_desktop_notifications", true); }); diff --git a/static/styles/settings.scss b/static/styles/settings.scss index ce657e39c3..942277961b 100644 --- a/static/styles/settings.scss +++ b/static/styles/settings.scss @@ -129,6 +129,17 @@ label { padding: 0px 20px; } +#notification_sound, +#play_notification_sound { + display: inline; + margin-right: 8px; + margin-bottom: 0px; +} + +.attributions_title { + margin-top: 24px; +} + .table.table-condensed.table-striped { margin: 0px; } diff --git a/static/templates/settings/notification-settings.handlebars b/static/templates/settings/notification-settings.handlebars index 61d9a1a38f..1efff15d1c 100644 --- a/static/templates/settings/notification-settings.handlebars +++ b/static/templates/settings/notification-settings.handlebars @@ -105,6 +105,23 @@ "is_checked" page_params.realm_name_in_notifications "label" settings_label.realm_name_in_notifications}} - + + +
+ + + {{t "Play sound" }} + +
+ diff --git a/zerver/lib/actions.py b/zerver/lib/actions.py index f04ebfe778..e96037ad2c 100644 --- a/zerver/lib/actions.py +++ b/zerver/lib/actions.py @@ -2715,6 +2715,17 @@ def bulk_add_subscriptions(streams: Iterable[Stream], [(sub.user_profile, stream) for (sub, stream) in subs_to_activate], already_subscribed) +def get_available_notification_sounds() -> List[str]: + notification_sounds_path = os.path.join(settings.STATIC_ROOT, 'audio/notification_sounds') + available_notification_sounds = [] + + for file_name in os.listdir(notification_sounds_path): + root, ext = os.path.splitext(file_name) + if ext == '.ogg': + available_notification_sounds.append(root) + + return available_notification_sounds + def notify_subscriptions_removed(user_profile: UserProfile, streams: Iterable[Stream], no_log: bool=False) -> None: if not no_log: @@ -3311,7 +3322,7 @@ def do_create_realm(string_id: str, name: str, "signups", realm.display_subdomain, signup_message) return realm -def do_change_notification_settings(user_profile: UserProfile, name: str, value: bool, +def do_change_notification_settings(user_profile: UserProfile, name: str, value: Union[bool, str], log: bool=True) -> None: """Takes in a UserProfile object, the name of a global notification preference to update, and the value to update to diff --git a/zerver/lib/events.py b/zerver/lib/events.py index 3713dc244d..7fecc030d5 100644 --- a/zerver/lib/events.py +++ b/zerver/lib/events.py @@ -40,6 +40,7 @@ from zerver.lib.actions import ( get_status_dict, streams_to_dicts_sorted, default_stream_groups_to_dicts_sorted, get_owned_bot_dicts, + get_available_notification_sounds, ) from zerver.lib.user_groups import user_groups_in_realm_serialized from zerver.tornado.event_queue import request_event_queue, get_user_events @@ -285,6 +286,7 @@ def fetch_initial_state_data(user_profile: UserProfile, if want('update_global_notifications'): for notification in UserProfile.notification_setting_types: state[notification] = getattr(user_profile, notification) + state['available_notification_sounds'] = get_available_notification_sounds() if want('zulip_version'): state['zulip_version'] = ZULIP_VERSION diff --git a/zerver/migrations/0194_userprofile_notification_sound.py b/zerver/migrations/0194_userprofile_notification_sound.py new file mode 100644 index 0000000000..b19b1b08d4 --- /dev/null +++ b/zerver/migrations/0194_userprofile_notification_sound.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.6 on 2018-03-12 03:18 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('zerver', '0193_realm_email_address_visibility'), + ] + + operations = [ + migrations.AddField( + model_name='userprofile', + name='notification_sound', + field=models.CharField(default='zulip', max_length=20), + ), + ] diff --git a/zerver/models.py b/zerver/models.py index 68fc7e7f6d..63d7cff179 100644 --- a/zerver/models.py +++ b/zerver/models.py @@ -744,6 +744,7 @@ class UserProfile(AbstractBaseUser, PermissionsMixin): enable_stream_email_notifications = models.BooleanField(default=False) # type: bool enable_stream_push_notifications = models.BooleanField(default=False) # type: bool enable_stream_sounds = models.BooleanField(default=False) # type: bool + notification_sound = models.CharField(max_length=20, default='zulip') # type: str # PM + @-mention notifications. enable_desktop_notifications = models.BooleanField(default=True) # type: bool @@ -865,6 +866,7 @@ class UserProfile(AbstractBaseUser, PermissionsMixin): enable_stream_push_notifications=bool, enable_stream_sounds=bool, message_content_in_email_notifications=bool, + notification_sound=str, pm_content_in_desktop_notifications=bool, realm_name_in_notifications=bool, ) diff --git a/zerver/tests/test_events.py b/zerver/tests/test_events.py index feb7184447..9d46a08276 100644 --- a/zerver/tests/test_events.py +++ b/zerver/tests/test_events.py @@ -1672,6 +1672,10 @@ class EventsRegisterTest(ZulipTestCase): @slow("Actually runs several full-stack fetching tests") def test_change_notification_settings(self) -> None: for notification_setting, v in self.user_profile.notification_setting_types.items(): + if notification_setting == "notification_sound": + # notification_sound is tested in its own test + continue + schema_checker = self.check_events_dict([ ('type', equals('update_global_notifications')), ('notification_name', equals(notification_setting)), @@ -1679,12 +1683,27 @@ class EventsRegisterTest(ZulipTestCase): ('setting', check_bool), ]) do_change_notification_settings(self.user_profile, notification_setting, False) + for setting_value in [True, False]: events = self.do_test(lambda: do_change_notification_settings( self.user_profile, notification_setting, setting_value, log=False)) error = schema_checker('events[0]', events[0]) self.assert_on_error(error) + def test_change_notification_sound(self) -> None: + notification_setting = "notification_sound" + schema_checker = self.check_events_dict([ + ('type', equals('update_global_notifications')), + ('notification_name', equals(notification_setting)), + ('user', check_string), + ('setting', equals("ding")), + ]) + + events = self.do_test(lambda: do_change_notification_settings( + self.user_profile, notification_setting, 'ding', log=False)) + error = schema_checker('events[0]', events[0]) + self.assert_on_error(error) + def test_realm_emoji_events(self) -> None: schema_checker = self.check_events_dict([ ('type', equals('realm_emoji')), diff --git a/zerver/tests/test_home.py b/zerver/tests/test_home.py index c320198d64..df04003a1f 100644 --- a/zerver/tests/test_home.py +++ b/zerver/tests/test_home.py @@ -47,6 +47,7 @@ class HomeTest(ZulipTestCase): # Keep this list sorted!!! expected_keys = [ "alert_words", + "available_notification_sounds", "avatar_source", "avatar_url", "avatar_url_medium", @@ -104,6 +105,7 @@ class HomeTest(ZulipTestCase): "needs_tutorial", "never_subscribed", "night_mode", + "notification_sound", "password_min_guesses", "password_min_length", "pm_content_in_desktop_notifications", diff --git a/zerver/tests/test_settings.py b/zerver/tests/test_settings.py index ada2ea8f81..37eea981c8 100644 --- a/zerver/tests/test_settings.py +++ b/zerver/tests/test_settings.py @@ -131,8 +131,39 @@ class ChangeSettingsTest(ZulipTestCase): # This is basically a don't-explode test. def test_notify_settings(self) -> None: for notification_setting in UserProfile.notification_setting_types: - self.check_for_toggle_param_patch("/json/settings/notifications", - notification_setting) + # `notification_sound` is a string not a boolean, so this test + # doesn't work for it. + # + # TODO: Make this work more like do_test_realm_update_api + if notification_setting is not 'notification_sound': + self.check_for_toggle_param_patch("/json/settings/notifications", + notification_setting) + + def test_change_notification_sound(self) -> None: + pattern = "/json/settings/notifications" + param = "notification_sound" + user_profile = self.example_user('hamlet') + self.login(user_profile.email) + + json_result = self.client_patch(pattern, + {param: ujson.dumps("invalid")}) + self.assert_json_error(json_result, "Invalid notification sound 'invalid'") + + json_result = self.client_patch(pattern, + {param: ujson.dumps("ding")}) + self.assert_json_success(json_result) + + # refetch user_profile object to correctly handle caching + user_profile = self.example_user('hamlet') + self.assertEqual(getattr(user_profile, param), "ding") + + json_result = self.client_patch(pattern, + {param: ujson.dumps('zulip')}) + + self.assert_json_success(json_result) + # refetch user_profile object to correctly handle caching + user_profile = self.example_user('hamlet') + self.assertEqual(getattr(user_profile, param), 'zulip') def test_toggling_boolean_user_display_settings(self) -> None: """Test updating each boolean setting in UserProfile property_types""" diff --git a/zerver/views/user_settings.py b/zerver/views/user_settings.py index f31451006c..50e2bc6425 100644 --- a/zerver/views/user_settings.py +++ b/zerver/views/user_settings.py @@ -12,7 +12,8 @@ from zerver.decorator import has_request_variables, \ from zerver.lib.actions import do_change_password, do_change_notification_settings, \ do_change_enter_sends, do_regenerate_api_key, do_change_avatar_fields, \ do_set_user_display_setting, validate_email, do_change_user_delivery_email, \ - do_start_email_change_process, check_change_full_name, do_change_user_delivery_email + do_start_email_change_process, check_change_full_name, do_change_user_delivery_email, \ + get_available_notification_sounds from zerver.lib.avatar import avatar_url from zerver.lib.send_email import send_email, FromAddress from zerver.lib.i18n import get_available_language_codes @@ -155,6 +156,7 @@ def json_change_notify_settings( enable_stream_email_notifications: Optional[bool]=REQ(validator=check_bool, default=None), enable_stream_push_notifications: Optional[bool]=REQ(validator=check_bool, default=None), enable_stream_sounds: Optional[bool]=REQ(validator=check_bool, default=None), + notification_sound: Optional[str]=REQ(validator=check_string, default=None), enable_desktop_notifications: Optional[bool]=REQ(validator=check_bool, default=None), enable_sounds: Optional[bool]=REQ(validator=check_bool, default=None), enable_offline_email_notifications: Optional[bool]=REQ(validator=check_bool, default=None), @@ -170,6 +172,10 @@ def json_change_notify_settings( # Stream notification settings. + if (notification_sound is not None and + notification_sound not in get_available_notification_sounds()): + raise JsonableError(_("Invalid notification sound '%s'") % (notification_sound,)) + req_vars = {k: v for k, v in list(locals().items()) if k in user_profile.notification_setting_types} for k, v in list(req_vars.items()):