settings: Add automatic theme detection feature.

With this implementation of the feature of the automatic theme
detection, we make the following changes in the backend, frontend and
documentation.

This replaces the previous night_mode boolean with an enum, with the
default value being to use the prefers-color-scheme feature of the
operating system to determine which theme to use.

Fixes: #14451.

Co-authored-by: @kPerikou <44238834+kPerikou@users.noreply.github.com>
This commit is contained in:
MariaGkoulta 2020-05-16 14:13:59 +03:00 committed by Tim Abbott
parent 9bd8ead32d
commit b10f156250
25 changed files with 185 additions and 61 deletions

View File

@ -692,20 +692,30 @@ with_overrides(function (override) {
$("body").fadeIn = (secs) => { assert_same(secs, 300); };
global.with_stub(function (stub) {
event = event_fixtures.update_display_settings__night_mode;
page_params.night_mode = false;
event = event_fixtures.update_display_settings__color_scheme_dark;
page_params.color_scheme = 1;
override('night_mode.enable', stub.f); // automatically checks if called
override('realm_logo.rerender', noop);
dispatch(event);
assert_same(page_params.night_mode, true);
assert(page_params.color_scheme, 2);
});
global.with_stub(function (stub) {
event = event_fixtures.update_display_settings__night_mode_false;
page_params.night_mode = true;
event = event_fixtures.update_display_settings__color_scheme_light;
page_params.color_scheme = 1;
override('night_mode.disable', stub.f); // automatically checks if called
override('realm_logo.rerender', noop);
dispatch(event);
assert(!page_params.night_mode);
assert(page_params.color_scheme, 3);
});
global.with_stub(function (stub) {
event = event_fixtures.update_display_settings__color_scheme_automatic;
page_params.color_scheme = 2;
override('night_mode.default_preference_checker', stub.f); // automatically checks if called
override('realm_logo.rerender', noop);
dispatch(event);
assert(page_params.color_scheme, 1);
});
global.with_stub(function (stub) {

View File

@ -478,16 +478,22 @@ exports.fixtures = {
setting: true,
},
update_display_settings__night_mode: {
update_display_settings__color_scheme_automatic: {
type: 'update_display_settings',
setting_name: 'night_mode',
setting: true,
setting_name: 'color_scheme',
setting: 1,
},
update_display_settings__night_mode_false: {
update_display_settings__color_scheme_dark: {
type: 'update_display_settings',
setting_name: 'night_mode',
setting: false,
setting_name: 'color_scheme',
setting: 2,
},
update_display_settings__color_scheme_light: {
type: 'update_display_settings',
setting_name: 'color_scheme',
setting: 3,
},
update_display_settings__starred_message_counts: {

View File

@ -1,9 +1,13 @@
exports.enable = function () {
$("body").addClass("night-mode");
$("body").removeClass("color-scheme-automatic").addClass("night-mode");
};
exports.disable = function () {
$("body").removeClass("night-mode");
$("body").removeClass("color-scheme-automatic").removeClass("night-mode");
};
exports.default_preference_checker = function () {
$("body").removeClass("night-mode").addClass("color-scheme-automatic");
};
window.night_mode = exports;

View File

@ -1,3 +1,5 @@
const settings_config = require("./settings_config");
exports.build_realm_logo_widget = function (upload_function, is_night) {
let logo_section_id = '#realm-day-logo-upload-widget';
let logo_source = page_params.realm_logo_source;
@ -73,7 +75,11 @@ exports.rerender = function () {
$("#realm-night-logo-upload-widget .image-block").attr("src", page_params.realm_night_logo_url);
}
if (page_params.night_mode && page_params.realm_night_logo_source !== 'D') {
if (page_params.color_scheme === settings_config.color_scheme_values.night.code &&
page_params.realm_night_logo_source !== 'D' ||
page_params.color_scheme === settings_config.color_scheme_values.automatic.code &&
page_params.realm_night_logo_source !== 'D' &&
(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
$("#realm-logo").attr("src", page_params.realm_night_logo_url);
} else {
$("#realm-logo").attr("src", page_params.realm_logo_url);

View File

@ -1,3 +1,5 @@
const settings_config = require("./settings_config");
exports.dispatch_normal_event = function dispatch_normal_event(event) {
const noop = function () {};
switch (event.type) {
@ -395,13 +397,13 @@ exports.dispatch_normal_event = function dispatch_normal_event(event) {
case 'update_display_settings': {
const user_display_settings = [
'color_scheme',
'default_language',
'demote_inactive_streams',
'dense_mode',
'emojiset',
'fluid_layout_width',
'high_contrast_mode',
'night_mode',
'left_side_userlist',
'timezone',
'twenty_four_hour_time',
@ -433,15 +435,18 @@ exports.dispatch_normal_event = function dispatch_normal_event(event) {
$("body").toggleClass("less_dense_mode");
$("body").toggleClass("more_dense_mode");
}
if (event.setting_name === 'night_mode') {
if (event.setting_name === 'color_scheme') {
$("body").fadeOut(300);
setTimeout(function () {
if (event.setting === true) {
if (event.setting === settings_config.color_scheme_values.night.code) {
night_mode.enable();
realm_logo.rerender();
} else {
} else if (event.setting === settings_config.color_scheme_values.day.code) {
night_mode.disable();
realm_logo.rerender();
} else {
night_mode.default_preference_checker();
realm_logo.rerender();
}
$("body").fadeIn(300);
}, 300);

View File

@ -37,7 +37,6 @@ function setup_settings_label() {
fluid_layout_width: i18n.t("Use full width on wide screens"),
high_contrast_mode: i18n.t("High contrast mode"),
left_side_userlist: i18n.t("Show user list on left sidebar in narrow windows"),
night_mode: i18n.t("Night mode"),
starred_message_counts: i18n.t("Show counts for starred messages"),
twenty_four_hour_time: i18n.t("Time format"),
translate_emoticons: i18n.t("Convert emoticons before sending (<code>:)</code> becomes 😃)"),
@ -58,6 +57,7 @@ exports.build_page = function () {
can_create_new_bots: settings_bots.can_create_new_bots(),
settings_label: exports.settings_label,
demote_inactive_streams_values: settings_config.demote_inactive_streams_values,
color_scheme_values: settings_config.color_scheme_values,
twenty_four_hour_time_values: settings_config.twenty_four_hour_time_values,
general_settings: settings_config.all_notifications().general_settings,
notification_settings: settings_config.all_notifications().settings,

View File

@ -27,6 +27,21 @@ exports.demote_inactive_streams_values = {
},
};
exports.color_scheme_values = {
automatic: {
code: 1,
description: i18n.t("Automatic"),
},
night: {
code: 2,
description: i18n.t("Night mode"),
},
day: {
code: 3,
description: i18n.t("Day mode"),
},
};
exports.twenty_four_hour_time_values = {
twenty_four_hour_clock: {
value: true,
@ -42,7 +57,6 @@ exports.get_all_display_settings = () => ({
settings: {
user_display_settings: [
"dense_mode",
"night_mode",
"high_contrast_mode",
"left_side_userlist",
"starred_message_counts",

View File

@ -29,6 +29,8 @@ exports.set_up = function () {
$("#demote_inactive_streams").val(page_params.demote_inactive_streams);
$("#color_scheme").val(page_params.color_scheme);
$("#twenty_four_hour_time").val(JSON.stringify(page_params.twenty_four_hour_time));
$(".emojiset_choice[value=" + page_params.emojiset + "]").prop("checked", true);
@ -82,6 +84,11 @@ exports.set_up = function () {
change_display_setting(data, '#display-settings-status');
});
$('#color_scheme').change(function () {
const data = {color_scheme: this.value};
change_display_setting(data, '#display-settings-status');
});
$('body').on('click', '.reload_link', function () {
window.location.reload();
});
@ -146,8 +153,8 @@ exports.update_page = function () {
$("#left_side_userlist").prop('checked', page_params.left_side_userlist);
$("#default_language_name").text(page_params.default_language_name);
$("#translate_emoticons").prop('checked', page_params.translate_emoticons);
$("#night_mode").prop('checked', page_params.night_mode);
$("#twenty_four_hour_time").val(JSON.stringify(page_params.twenty_four_hour_time));
$("#color_scheme").val(JSON.stringify(page_params.color_scheme));
// TODO: Set emojiset selector here.
// Longer term, we'll want to automate this function

View File

@ -786,3 +786,9 @@ on a dark background, and don't change the dark labels dark either. */
background-color: hsla(0, 0%, 0%, 0.2);
}
}
@media (prefers-color-scheme: dark) {
.color-scheme-automatic {
@extend body.night-mode;
}
}

View File

@ -21,6 +21,14 @@
<h3 class="inline-block">{{t "Display settings" }}</h3>
<div class="alert-notification" id="display-settings-status"></div>
<div class="input-group">
<label for="color_scheme" class="dropdown-title">{{t "Color scheme" }}
</label>
<select name="color_scheme" id="color_scheme">
{{> dropdown_options_widget option_values=color_scheme_values}}
</select>
</div>
{{#each display_settings.settings.user_display_settings}}
{{> settings_checkbox
setting_name=this

View File

@ -10,6 +10,11 @@ below features are supported.
## Changes in Zulip 2.2
**Feature level 21**
* `PATCH /settings/display`: Replaced the `night_mode` boolean with
`color_scheme` as part of supporting automatic night theme detection.
**Feature level 20**
* Added support for inviting users as organization owners to the

View File

@ -29,7 +29,7 @@
{% endblock %}
</head>
<body {% if night_mode %}class="night-mode"{% endif %}>
<body {% if color_scheme == 1 %} class="color-scheme-automatic" {% elif color_scheme == 2 %} class="night-mode" {% endif %}>
{% block content %}
{% endblock %}

View File

@ -1,15 +1,21 @@
# Night mode
By default, Zulip has a white background. Zulip also provides a
"night mode", which is great for working in a dark space.
Zulip provides both a light theme and a night theme, which is great
for working in a dark space.
### Enable night mode
## Manage color theme
{start_tabs}
{settings_tab|display-settings}
2. Under **Display settings**, select **Night mode**.
2. Under **Display settings**, configure **Color scheme**.
{end_tabs}
.
The default is **Automatic**, which detects which theme to use based
on the color scheme used by your operating system.
You can also specifc **Night mode** or **Day mode** if you'd like
Zulip to use the same color scheme regardless of your operating system
configuration.

View File

@ -29,7 +29,7 @@ DESKTOP_WARNING_VERSION = "5.2.0"
#
# Changes should be accompanied by documentation explaining what the
# new level means in templates/zerver/api/changelog.md.
API_FEATURE_LEVEL = 20
API_FEATURE_LEVEL = 21
# Bump the minor PROVISION_VERSION to indicate that folks should provision
# only when going from an old version of the code to a newer version. Bump

View File

@ -9,7 +9,7 @@ from zerver.models import UserProfile
def process_zcommands(content: str, user_profile: UserProfile) -> Dict[str, Any]:
def change_mode_setting(command: str, switch_command: str,
setting: str, setting_value: bool) -> str:
setting: str, setting_value: int) -> str:
msg = 'Changed to {command} mode! To revert ' \
'{command} mode, type `/{switch_command}`.'.format(
command=command,
@ -27,19 +27,19 @@ def process_zcommands(content: str, user_profile: UserProfile) -> Dict[str, Any]
if command == 'ping':
return dict()
elif command == 'night':
if user_profile.night_mode:
if user_profile.color_scheme == UserProfile.COLOR_SCHEME_NIGHT:
return dict(msg='You are still in night mode.')
return dict(msg=change_mode_setting(command=command,
switch_command='day',
setting='night_mode',
setting_value=True))
setting='color_scheme',
setting_value=UserProfile.COLOR_SCHEME_NIGHT))
elif command == 'day':
if not user_profile.night_mode:
if user_profile.color_scheme == UserProfile.COLOR_SCHEME_LIGHT:
return dict(msg='You are still in day mode.')
return dict(msg=change_mode_setting(command=command,
switch_command='night',
setting='night_mode',
setting_value=False))
setting='color_scheme',
setting_value=UserProfile.COLOR_SCHEME_LIGHT))
elif command == 'fluid-width':
if user_profile.fluid_layout_width:
return dict(msg='You are still in fluid width mode.')

View File

@ -0,0 +1,35 @@
# Generated by Django 2.2.13 on 2020-06-20 15:22
from django.db import migrations, models
from django.db.backends.postgresql.schema import DatabaseSchemaEditor
from django.db.migrations.state import StateApps
COLOR_SCHEME_AUTOMATIC = 1
COLOR_SCHEME_NIGHT = 2
# Set color_scheme to night mode, if night_mode is True.
def set_color_scheme_to_night_mode(apps: StateApps, schema_editor: DatabaseSchemaEditor) -> None:
UserProfile = apps.get_model("zerver", "UserProfile")
UserProfile.objects.filter(night_mode=True).update(color_scheme=COLOR_SCHEME_NIGHT)
class Migration(migrations.Migration):
dependencies = [
('zerver', '0289_tighten_attachment_size'),
]
operations = [
migrations.AddField(
model_name='userprofile',
name='color_scheme',
field=models.PositiveSmallIntegerField(default=COLOR_SCHEME_AUTOMATIC),
),
migrations.RunPython(
set_color_scheme_to_night_mode,
reverse_code=migrations.RunPython.noop,
elidable=True),
migrations.RemoveField(
model_name='userprofile',
name='night_mode',
),
]

View File

@ -999,10 +999,18 @@ class UserProfile(AbstractBaseUser, PermissionsMixin):
dense_mode: bool = models.BooleanField(default=True)
fluid_layout_width: bool = models.BooleanField(default=False)
high_contrast_mode: bool = models.BooleanField(default=False)
night_mode: bool = models.BooleanField(default=False)
translate_emoticons: bool = models.BooleanField(default=False)
twenty_four_hour_time: bool = models.BooleanField(default=False)
starred_message_counts: bool = models.BooleanField(default=False)
COLOR_SCHEME_AUTOMATIC = 1
COLOR_SCHEME_NIGHT = 2
COLOR_SCHEME_LIGHT = 3
COLOR_SCHEME_CHOICES = [
COLOR_SCHEME_AUTOMATIC,
COLOR_SCHEME_NIGHT,
COLOR_SCHEME_LIGHT
]
color_scheme = models.PositiveSmallIntegerField(default=COLOR_SCHEME_AUTOMATIC)
# UI setting controlling Zulip's behavior of demoting in the sort
# order and graying out streams with no recent traffic. The
@ -1069,6 +1077,7 @@ class UserProfile(AbstractBaseUser, PermissionsMixin):
# Define the types of the various automatically managed properties
property_types = dict(
color_scheme=int,
default_language=str,
demote_inactive_streams=int,
dense_mode=bool,
@ -1076,7 +1085,6 @@ class UserProfile(AbstractBaseUser, PermissionsMixin):
fluid_layout_width=bool,
high_contrast_mode=bool,
left_side_userlist=bool,
night_mode=bool,
starred_message_counts=bool,
timezone=str,
translate_emoticons=bool,

View File

@ -1852,6 +1852,7 @@ class EventsRegisterTest(ZulipTestCase):
default_language = ['es', 'de', 'en'],
timezone = ['US/Mountain', 'US/Samoa', 'Pacific/Galapogos', ''],
demote_inactive_streams = [2, 3, 1],
color_scheme = [2, 3, 1]
)
property_type = UserProfile.property_types[setting_name]

View File

@ -61,6 +61,7 @@ class HomeTest(ZulipTestCase):
"bot_types",
"can_create_streams",
"can_subscribe_other_users",
"color_scheme",
"cross_realm_bots",
"custom_profile_field_types",
"custom_profile_fields",
@ -117,7 +118,6 @@ class HomeTest(ZulipTestCase):
"narrow_stream",
"needs_tutorial",
"never_subscribed",
"night_mode",
"notification_sound",
"password_min_guesses",
"password_min_length",
@ -761,34 +761,34 @@ class HomeTest(ZulipTestCase):
def test_compute_navbar_logo_url(self) -> None:
user_profile = self.example_user("hamlet")
page_params = {"night_mode": True}
page_params = {"color_scheme": user_profile.COLOR_SCHEME_NIGHT}
add_realm_logo_fields(page_params, user_profile.realm)
self.assertEqual(compute_navbar_logo_url(page_params),
"/static/images/logo/zulip-org-logo.png?version=0")
page_params = {"night_mode": False}
page_params = {"color_scheme": user_profile.COLOR_SCHEME_LIGHT}
add_realm_logo_fields(page_params, user_profile.realm)
self.assertEqual(compute_navbar_logo_url(page_params),
"/static/images/logo/zulip-org-logo.png?version=0")
do_change_logo_source(user_profile.realm, Realm.LOGO_UPLOADED, night=False)
page_params = {"night_mode": True}
page_params = {"color_scheme": user_profile.COLOR_SCHEME_NIGHT}
add_realm_logo_fields(page_params, user_profile.realm)
self.assertEqual(compute_navbar_logo_url(page_params),
f"/user_avatars/{user_profile.realm_id}/realm/logo.png?version=2")
page_params = {"night_mode": False}
page_params = {"color_scheme": user_profile.COLOR_SCHEME_LIGHT}
add_realm_logo_fields(page_params, user_profile.realm)
self.assertEqual(compute_navbar_logo_url(page_params),
f"/user_avatars/{user_profile.realm_id}/realm/logo.png?version=2")
do_change_logo_source(user_profile.realm, Realm.LOGO_UPLOADED, night=True)
page_params = {"night_mode": True}
page_params = {"color_scheme": user_profile.COLOR_SCHEME_NIGHT}
add_realm_logo_fields(page_params, user_profile.realm)
self.assertEqual(compute_navbar_logo_url(page_params),
f"/user_avatars/{user_profile.realm_id}/realm/night_logo.png?version=2")
page_params = {"night_mode": False}
page_params = {"color_scheme": user_profile.COLOR_SCHEME_LIGHT}
add_realm_logo_fields(page_params, user_profile.realm)
self.assertEqual(compute_navbar_logo_url(page_params),
f"/user_avatars/{user_profile.realm_id}/realm/logo.png?version=2")
@ -796,12 +796,12 @@ class HomeTest(ZulipTestCase):
# This configuration isn't super supported in the UI and is a
# weird choice, but we have a test for it anyway.
do_change_logo_source(user_profile.realm, Realm.LOGO_DEFAULT, night=False)
page_params = {"night_mode": True}
page_params = {"color_scheme": user_profile.COLOR_SCHEME_NIGHT}
add_realm_logo_fields(page_params, user_profile.realm)
self.assertEqual(compute_navbar_logo_url(page_params),
f"/user_avatars/{user_profile.realm_id}/realm/night_logo.png?version=2")
page_params = {"night_mode": False}
page_params = {"color_scheme": user_profile.COLOR_SCHEME_LIGHT}
add_realm_logo_fields(page_params, user_profile.realm)
self.assertEqual(compute_navbar_logo_url(page_params),
"/static/images/logo/zulip-org-logo.png?version=0")

View File

@ -83,9 +83,8 @@ class OpenGraphTest(ZulipTestCase):
self.check_title_and_description(
'/help/night-mode',
"Night mode (Zulip Help Center)",
['By default, Zulip has a white background. ',
'Zulip also provides a "night mode", which is great for working in a dark space.'],
[],
['Zulip provides both a white background and a "night mode", which is great for working in a dark space.'],
[]
)
def test_settings_tab(self) -> None:

View File

@ -322,6 +322,7 @@ class ChangeSettingsTest(ZulipTestCase):
emojiset = 'google',
timezone = 'US/Mountain',
demote_inactive_streams = 2,
color_scheme = 2,
)
self.login('hamlet')

View File

@ -1020,11 +1020,12 @@ class UserProfileTest(ZulipTestCase):
iago = self.example_user("iago")
cordelia = self.example_user("cordelia")
hamlet = self.example_user("hamlet")
hamlet.color_scheme = UserProfile.COLOR_SCHEME_LIGHT
cordelia.default_language = "de"
cordelia.emojiset = "twitter"
cordelia.timezone = "America/Phoenix"
cordelia.night_mode = True
cordelia.color_scheme = UserProfile.COLOR_SCHEME_NIGHT
cordelia.enable_offline_email_notifications = False
cordelia.enable_stream_push_notifications = True
cordelia.enter_sends = False
@ -1055,9 +1056,9 @@ class UserProfileTest(ZulipTestCase):
self.assertEqual(cordelia.timezone, "America/Phoenix")
self.assertEqual(hamlet.timezone, "")
self.assertEqual(iago.night_mode, True)
self.assertEqual(cordelia.night_mode, True)
self.assertEqual(hamlet.night_mode, False)
self.assertEqual(iago.color_scheme, UserProfile.COLOR_SCHEME_NIGHT)
self.assertEqual(cordelia.color_scheme, UserProfile.COLOR_SCHEME_NIGHT)
self.assertEqual(hamlet.color_scheme, UserProfile.COLOR_SCHEME_LIGHT)
self.assertEqual(iago.enable_offline_email_notifications, False)
self.assertEqual(cordelia.enable_offline_email_notifications, False)

View File

@ -1,4 +1,5 @@
from zerver.lib.test_classes import ZulipTestCase
from zerver.models import UserProfile
class ZcommandTest(ZulipTestCase):
@ -24,7 +25,7 @@ class ZcommandTest(ZulipTestCase):
def test_night_zcommand(self) -> None:
self.login('hamlet')
user = self.example_user('hamlet')
user.night_mode = False
user.color_scheme = UserProfile.COLOR_SCHEME_LIGHT
user.save()
payload = dict(command="/night")
@ -39,7 +40,7 @@ class ZcommandTest(ZulipTestCase):
def test_day_zcommand(self) -> None:
self.login('hamlet')
user = self.example_user('hamlet')
user.night_mode = True
user.color_scheme = UserProfile.COLOR_SCHEME_NIGHT
user.save()
payload = dict(command="/day")

View File

@ -129,7 +129,7 @@ def get_bot_types(user_profile: Optional[UserProfile]) -> List[Dict[str, object]
return bot_types
def compute_navbar_logo_url(page_params: Dict[str, Any]) -> str:
if page_params["night_mode"] and page_params["realm_night_logo_source"] != Realm.LOGO_DEFAULT:
if page_params["color_scheme"] == 2 and page_params["realm_night_logo_source"] != Realm.LOGO_DEFAULT:
navbar_logo_url = page_params["realm_night_logo_url"]
else:
navbar_logo_url = page_params["realm_logo_url"]
@ -315,13 +315,13 @@ def home_real(request: HttpRequest) -> HttpResponse:
csp_nonce = generate_random_token(48)
if user_profile is not None:
night_mode = user_profile.night_mode
color_scheme = user_profile.color_scheme
is_guest = user_profile.is_guest
is_realm_owner = user_profile.is_realm_owner
is_realm_admin = user_profile.is_realm_admin
show_webathena = user_profile.realm.webathena_enabled
else: # nocoverage
night_mode = False
color_scheme = UserProfile.COLOR_SCHEME_AUTOMATIC
is_guest = False
is_realm_admin = False
is_realm_owner = False
@ -342,7 +342,7 @@ def home_real(request: HttpRequest) -> HttpResponse:
'is_owner': is_realm_owner,
'is_admin': is_realm_admin,
'is_guest': is_guest,
'night_mode': night_mode,
'color_scheme': color_scheme,
'navbar_logo_url': navbar_logo_url,
'show_webathena': show_webathena,
'embedded': narrow_stream is not None,

View File

@ -165,7 +165,8 @@ def update_display_settings_backend(
starred_message_counts: Optional[bool]=REQ(validator=check_bool, default=None),
fluid_layout_width: Optional[bool]=REQ(validator=check_bool, default=None),
high_contrast_mode: Optional[bool]=REQ(validator=check_bool, default=None),
night_mode: Optional[bool]=REQ(validator=check_bool, default=None),
color_scheme: Optional[int]=REQ(validator=check_int_in(
UserProfile.COLOR_SCHEME_CHOICES), default=None),
translate_emoticons: Optional[bool]=REQ(validator=check_bool, default=None),
default_language: Optional[str]=REQ(validator=check_string, default=None),
left_side_userlist: Optional[bool]=REQ(validator=check_bool, default=None),