user_settings: Add user setting to control the user list style.

Added a user_list_style personal user setting to the bottom of
Settings > Display settings > Theme section which controls the look
of the right sidebar user list.

The radio button UI includes a preview of what the styles look like.

The setting is intended to eventually have 3 possible values: COMPACT,
WITH_STATUS and WITH_AVATAR; the final value is not yet implemented.

Co-authored-by: Tim Abbott <tabbott@zulip.com>
This commit is contained in:
Raghav Luthra 2022-08-13 02:11:06 +05:30 committed by Tim Abbott
parent 5f626428de
commit 4dad9fa158
29 changed files with 374 additions and 22 deletions

View File

@ -376,6 +376,7 @@ test("handlers", ({override, mock_template}) => {
test("first/prev/next", ({override, mock_template}) => {
let rendered_alice;
let rendered_fred;
user_settings.user_list_style = 2;
mock_template("presence_row.hbs", false, (data) => {
switch (data.user_id) {
@ -392,6 +393,12 @@ test("first/prev/next", ({override, mock_template}) => {
user_circle_status: "translated: Active",
user_id: alice.user_id,
status_emoji_info: undefined,
status_text: undefined,
user_list_style: {
COMPACT: false,
WITH_STATUS: true,
WITH_AVATAR: false,
},
});
break;
case fred.user_id:
@ -407,6 +414,12 @@ test("first/prev/next", ({override, mock_template}) => {
user_circle_status: "translated: Active",
faded: false,
status_emoji_info: undefined,
status_text: undefined,
user_list_style: {
COMPACT: false,
WITH_STATUS: true,
WITH_AVATAR: false,
},
});
break;
/* istanbul ignore next */
@ -438,6 +451,7 @@ test("first/prev/next", ({override, mock_template}) => {
});
test("insert_one_user_into_empty_list", ({override, mock_template}) => {
user_settings.user_list_style = 2;
mock_template("presence_row.hbs", true, (data, html) => {
assert.deepEqual(data, {
href: "#narrow/pm-with/1-alice",
@ -450,6 +464,12 @@ test("insert_one_user_into_empty_list", ({override, mock_template}) => {
user_circle_status: "translated: Active",
faded: true,
status_emoji_info: undefined,
status_text: undefined,
user_list_style: {
COMPACT: false,
WITH_STATUS: true,
WITH_AVATAR: false,
},
});
assert.ok(html.startsWith("<li data-user-id="));
return html;

View File

@ -509,6 +509,7 @@ test("get_items_for_users", () => {
people.add_active_user(fred);
user_status.set_away(alice.user_id);
user_settings.emojiset = "google";
user_settings.user_list_style = 2;
const status_emoji_info = {
emoji_alt_code: false,
emoji_name: "car",
@ -521,6 +522,12 @@ test("get_items_for_users", () => {
user_status.set_status_emoji({user_id, ...status_emoji_info});
}
const user_list_style = {
COMPACT: false,
WITH_STATUS: true,
WITH_AVATAR: false,
};
assert.deepEqual(buddy_data.get_items_for_users(user_ids), [
{
faded: false,
@ -530,9 +537,11 @@ test("get_items_for_users", () => {
name: "Human Myself",
num_unread: 0,
status_emoji_info,
status_text: undefined,
user_circle_class: "user_circle_green",
user_circle_status: "translated: Active",
user_id: 1001,
user_list_style,
},
{
faded: false,
@ -542,9 +551,11 @@ test("get_items_for_users", () => {
name: "Alice Smith",
num_unread: 0,
status_emoji_info,
status_text: undefined,
user_circle_class: "user_circle_empty_line",
user_circle_status: "translated: Unavailable",
user_id: 1002,
user_list_style,
},
{
faded: false,
@ -554,9 +565,11 @@ test("get_items_for_users", () => {
name: "Fred Flintstone",
num_unread: 0,
status_emoji_info,
status_text: undefined,
user_circle_class: "user_circle_empty",
user_circle_status: "translated: Offline",
user_id: 1003,
user_list_style,
},
]);
});

View File

@ -885,6 +885,17 @@ run_test("user_settings", ({override}) => {
assert_same(user_settings.demote_inactive_streams, 2);
}
{
const stub = make_stub();
event = event_fixtures.user_settings__user_list_style;
override(settings_display, "report_user_list_style_change", stub.f);
user_settings.user_list_style = 1;
override(activity, "build_user_sidebar", stub.f);
dispatch(event);
assert.equal(stub.num_calls, 2);
assert_same(user_settings.user_list_style, 2);
}
event = event_fixtures.user_settings__enter_sends;
user_settings.enter_sends = false;
dispatch(event);

View File

@ -915,6 +915,13 @@ exports.fixtures = {
value: true,
},
user_settings__user_list_style: {
type: "user_settings",
op: "update",
property: "user_list_style",
value: 2,
},
user_status__revoke_away: {
type: "user_status",
user_id: 63,

View File

@ -89,6 +89,7 @@ function get_realm_level_notification_settings(options) {
export function build_page() {
const options = {
custom_profile_field_types: page_params.custom_profile_field_types,
full_name: page_params.full_name,
realm_name: page_params.realm_name,
realm_org_type: page_params.realm_org_type,
realm_available_video_chat_providers: page_params.realm_available_video_chat_providers,
@ -158,6 +159,7 @@ export function build_page() {
settings_config.common_message_policy_values.by_admins_only.code,
...settings_org.get_organization_settings_options(),
demote_inactive_streams_values: settings_config.demote_inactive_streams_values,
user_list_style_values: settings_config.user_list_style_values,
color_scheme_values: settings_config.color_scheme_values,
default_view_values: settings_config.default_view_values,
settings_object: realm_user_settings_defaults,

View File

@ -8,6 +8,7 @@ import * as people from "./people";
import * as presence from "./presence";
import * as timerender from "./timerender";
import * as unread from "./unread";
import {user_settings} from "./user_settings";
import * as user_status from "./user_status";
import * as util from "./util";
@ -179,6 +180,13 @@ export function info_for(user_id) {
const status_emoji_info = user_status.get_status_emoji(user_id);
const user_circle_status = status_description(user_id);
const status_text = user_status.get_status_text(user_id);
const user_list_style_value = user_settings.user_list_style;
const user_list_style = {
COMPACT: user_list_style_value === 1,
WITH_STATUS: user_list_style_value === 2,
WITH_AVATAR: user_list_style_value === 3,
};
return {
href: hash_util.pm_with_url(person.email),
@ -190,6 +198,8 @@ export function info_for(user_id) {
num_unread: get_num_unread(user_id),
user_circle_class,
user_circle_status,
status_text,
user_list_style,
};
}

View File

@ -34,6 +34,7 @@ export type RealmDefaultSettings = {
starred_message_counts: boolean;
translate_emoticons: boolean;
twenty_four_hour_time: boolean;
user_list_style: boolean;
wildcard_mentions_notify: boolean;
};

View File

@ -621,6 +621,7 @@ export function dispatch_normal_event(event) {
"twenty_four_hour_time",
"translate_emoticons",
"display_emoji_reaction_users",
"user_list_style",
"starred_message_counts",
"send_stream_typing_notifications",
"send_private_typing_notifications",
@ -652,6 +653,12 @@ export function dispatch_normal_event(event) {
stream_list.update_streams_sidebar();
stream_data.set_filter_out_inactives();
}
if (event.property === "user_list_style") {
settings_display.report_user_list_style_change(
settings_display.user_settings_panel,
);
activity.build_user_sidebar();
}
if (event.property === "dense_mode") {
$("body").toggleClass("less_dense_mode");
$("body").toggleClass("more_dense_mode");

View File

@ -83,6 +83,7 @@ export function build_page() {
can_create_new_bots: settings_bots.can_create_new_bots(),
settings_label,
demote_inactive_streams_values: settings_config.demote_inactive_streams_values,
user_list_style_values: settings_config.user_list_style_values,
color_scheme_values: settings_config.color_scheme_values,
default_view_values: settings_config.default_view_values,
twenty_four_hour_time_values: settings_config.twenty_four_hour_time_values,

View File

@ -39,6 +39,22 @@ export const demote_inactive_streams_values = {
},
};
export const user_list_style_values = {
compact: {
code: 1,
description: $t({defaultMessage: "Compact"}),
},
with_status: {
code: 2,
description: $t({defaultMessage: "Show status text"}),
},
// The `with_avatar` design in still in discussion.
// with_avatar: {
// code: 3,
// description: $t({defaultMessage: "Show status text and avatar"}),
// },
};
export const default_view_values = {
recent_topics: {
code: "recent_topics",

View File

@ -166,6 +166,9 @@ export function set_up(settings_panel) {
$container
.find(`.setting_emojiset_choice[value="${CSS.escape(settings_object.emojiset)}"]`)
.prop("checked", true);
$container
.find(`.setting_user_list_style_choice[value=${settings_object.user_list_style}]`)
.prop("checked", true);
if (for_realm_settings) {
// For the realm-level defaults page, we use the common
@ -224,6 +227,29 @@ export function set_up(settings_panel) {
},
});
});
$container.find(".setting_user_list_style_choice").on("click", function () {
const data = {user_list_style: $(this).val()};
const current_user_list_style = settings_object.user_list_style;
if (current_user_list_style === data.user_list_style) {
return;
}
const $spinner = $container.find(".theme-settings-status").expectOne();
loading.make_indicator($spinner, {text: settings_ui.strings.saving});
channel.patch({
url: "/json/settings",
data,
success() {},
error(xhr) {
ui_report.error(
settings_ui.strings.failure_html,
xhr,
$container.find(".theme-settings-status").expectOne(),
);
},
});
});
}
export async function report_emojiset_change(settings_panel) {
@ -247,6 +273,25 @@ export async function report_emojiset_change(settings_panel) {
}
}
export async function report_user_list_style_change(settings_panel) {
// TODO: Clean up how this works so we can use
// change_display_setting. The challenge is that we don't want to
// report success before the server_events request returns that
// causes the actual sprite sheet to change. The current
// implementation is wrong, though, in that it displays the UI
// update in all active browser windows.
const $spinner = $(settings_panel.container).find(".theme-settings-status");
if ($spinner.length) {
loading.destroy_indicator($spinner);
ui_report.success(
$t_html({defaultMessage: "User list style changed successfully!"}),
$spinner.expectOne(),
);
$spinner.expectOne();
settings_ui.display_checkmark($spinner);
}
}
export function update_page(property) {
if (!overlays.settings_open()) {
return;
@ -262,8 +307,8 @@ export function update_page(property) {
}
// settings_org.set_input_element_value doesn't support radio
// button widgets like this one.
if (property === "emojiset") {
// button widgets like these.
if (property === "emojiset" || property === "user_list_style") {
$container.find(`input[value=${CSS.escape(value)}]`).prop("checked", true);
return;
}

View File

@ -244,8 +244,14 @@ function get_subsection_property_elements(element) {
// structure, it needs custom code.
const $color_scheme_elem = $subsection.find(".setting_color_scheme");
const $emojiset_elem = $subsection.find("input[name='emojiset']:checked");
const $user_list_style_elem = $subsection.find("input[name='user_list_style']:checked");
const $translate_emoticons_elem = $subsection.find(".translate_emoticons");
return [$color_scheme_elem, $emojiset_elem, $translate_emoticons_elem];
return [
$color_scheme_elem,
$emojiset_elem,
$user_list_style_elem,
$translate_emoticons_elem,
];
}
return Array.from($subsection.find(".prop-element"));
}
@ -533,13 +539,21 @@ function discard_property_element_changes(elem, for_realm_default_settings) {
);
break;
case "emojiset":
// Because the emojiset widget has a unique radio button
// structure, it needs custom reset code.
// Because this widget has a radio button structure, it
// needs custom reset code.
$elem
.closest(".org-subsection-parent")
.find(`.setting_emojiset_choice[value='${CSS.escape(property_value)}'`)
.prop("checked", true);
break;
case "user_list_style":
// Because this widget has a radio button structure, it
// needs custom reset code.
$elem
.closest(".org-subsection-parent")
.find(`.setting_user_list_style_choice[value='${CSS.escape(property_value)}'`)
.prop("checked", true);
break;
case "email_notifications_batching_period_seconds":
case "email_notification_batching_period_edit_minutes":
settings_notifications.set_notification_batching_ui(

View File

@ -29,8 +29,8 @@ export function update_page(property) {
let value = realm_user_settings_defaults[property];
// settings_org.set_input_element_value doesn't support radio
// button widgets like this one.
if (property === "emojiset") {
// button widgets like these.
if (property === "emojiset" || property === "user_list_style") {
$container.find(`input[value=${CSS.escape(value)}]`).prop("checked", true);
return;
}

View File

@ -37,6 +37,7 @@ export type UserSettings = (StreamNotificationSettings & PmNotificationSettings)
pm_content_in_desktop_notifications: boolean;
presence_enabled: boolean;
realm_name_in_notifications: boolean;
user_list_style: number;
starred_message_counts: boolean;
translate_emoticons: boolean;
display_emoji_reaction_users: boolean;

View File

@ -130,6 +130,24 @@
align-items: flex-start;
justify-content: space-between;
.user-name-and-status-emoji {
display: flex;
}
.status-text {
display: block;
width: 170px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
opacity: 0.75;
font-size: 90%;
}
span.status-text:not(:empty) {
margin-top: -3px;
}
.unread_count {
display: none;
margin-top: 2.5px;

View File

@ -918,15 +918,10 @@ input[type="checkbox"] {
}
}
.emojiset_choices {
width: 250px;
.emojiset_choices,
.user_list_style_values {
padding: 0 10px;
.emoji {
height: 22px;
width: 22px;
}
label {
border-bottom: 1px solid hsla(0, 0%, 0%, 0.2);
padding: 8px 0 10px;
@ -944,11 +939,57 @@ input[type="checkbox"] {
font-weight: 600;
}
}
}
.right {
float: right;
}
}
}
.emojiset_choices {
width: 250px;
.emoji {
height: 22px;
width: 22px;
}
}
$right_sidebar_width: 170px;
$option_title_width: 180px;
.user_list_style_values {
max-width: calc($right_sidebar_width + $option_title_width);
.preview {
background-color: inherit !important;
/* Match the 170px width of the right sidebar region for the name/status,
doing something reasonable if the window shrinks. */
width: calc(100% - $option_title_width);
text-align: left;
white-space: nowrap;
text-overflow: ellipsis;
overflow-x: hidden;
overflow-y: visible;
position: relative;
height: 36px;
.user-name-and-status-text {
margin-top: -4px;
display: flex;
flex-direction: column;
}
.status-text {
display: inline-block;
opacity: 0.75;
font-size: 90%;
&:not(:empty) {
margin-top: -3px;
}
}
}
}
.open-user-form {

View File

@ -5,8 +5,18 @@
href="{{href}}"
data-user-id="{{user_id}}"
data-name="{{name}}">
{{#if user_list_style.WITH_STATUS}}
<div>
<div class="user-name-and-status-emoji">
<span class="user-name">{{name}}</span>
{{> status_emoji status_emoji_info}}
</div>
<span class="status-text">{{status_text}}</span>
</div>
{{else}}
<span class="user-name">{{name}}</span>
{{> status_emoji status_emoji_info}}
{{/if}}
</a>
<span class="unread_count">{{#if num_unread}}{{num_unread}}{{/if}}</span>
</div>

View File

@ -77,6 +77,33 @@
label=settings_label.display_emoji_reaction_users
prefix=prefix}}
{{/if}}
<div class="input-group">
<label class="title">{{t "User list style" }}</label>
<div class="user_list_style_values grey-box">
{{#each user_list_style_values}}
<label>
<input type="radio" class="setting_user_list_style_choice prop-element" name="user_list_style" value="{{this.code}}" data-setting-widget-type="radio-group"/>
<span>{{this.description}}</span>
<span class="right preview">
{{#if (eq this.code 1)}}
<span class="user-name">{{../full_name}}</span>
<div class="emoji status_emoji emoji-1f3e0"></div>
{{/if}}
{{#if (eq this.code 2)}}
<div class="user-name-and-status-text">
<div class="user-name-and-status-emoji">
<span class="user-name">{{../full_name}}</span>
{{> ../status_emoji emoji_code="1f3e0"}}
</div>
<span class="status-text">{{t "Working remotely" }}</span>
</div>
{{/if}}
</span>
</label>
{{/each}}
</div>
</div>
</div>
<div class="advanced-settings {{#if for_realm_settings}}org-subsection-parent{{else}}subsection-parent{{/if}}">

View File

@ -6,7 +6,7 @@
{{#*inline "z-link"}}<a href="/help/configure-default-new-user-settings" target="_blank" rel="noopener noreferrer">{{> @partial-block }}</a>{{/inline}}
{{/tr}}
</div>
{{> display_settings prefix="realm_" for_realm_settings=true}}
{{> display_settings prefix="realm_" for_realm_settings=true full_name=full_name}}
{{> notification_settings prefix="realm_" for_realm_settings=true}}

View File

@ -20,6 +20,14 @@ format used by the Zulip server that they are interacting with.
## Changes in Zulip 6.0
**Feature level 141**
* [`POST /register`](/api/register-queue), [`PATCH
/settings`](/api/update-settings), [`PATCH
/realm/user_settings_defaults`](/api/update-realm-user-settings-defaults):
Added new `user_list_style` display setting, which controls the
layout of the right sidebar.
**Feature level 140**
* [`POST /register`](/api/register-queue): Added string field `server_emoji_data_url`

View File

@ -33,7 +33,7 @@ DESKTOP_WARNING_VERSION = "5.4.3"
# Changes should be accompanied by documentation explaining what the
# new level means in templates/zerver/api/changelog.md, as well as
# "**Changes**" entries in the endpoint's documentation in `zulip.yaml`.
API_FEATURE_LEVEL = 140
API_FEATURE_LEVEL = 141
# 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

@ -0,0 +1,23 @@
# Generated by Django 4.0.6 on 2022-08-14 18:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("zerver", "0406_alter_realm_message_content_edit_limit_seconds"),
]
operations = [
migrations.AddField(
model_name="realmuserdefault",
name="user_list_style",
field=models.PositiveSmallIntegerField(default=2),
),
migrations.AddField(
model_name="userprofile",
name="user_list_style",
field=models.PositiveSmallIntegerField(default=2),
),
]

View File

@ -1523,6 +1523,17 @@ class UserBaseSettings(models.Model):
default=GOOGLE_EMOJISET, choices=EMOJISET_CHOICES, max_length=20
)
# User list style
USER_LIST_STYLE_COMPACT = 1
USER_LIST_STYLE_WITH_STATUS = 2
USER_LIST_STYLE_WITH_AVATAR = 3
USER_LIST_STYLE_CHOICES = [
USER_LIST_STYLE_COMPACT,
USER_LIST_STYLE_WITH_STATUS,
USER_LIST_STYLE_WITH_AVATAR,
]
user_list_style: int = models.PositiveSmallIntegerField(default=USER_LIST_STYLE_WITH_STATUS)
### Notifications settings. ###
email_notifications_batching_period_seconds: int = models.IntegerField(default=120)
@ -1621,6 +1632,7 @@ class UserBaseSettings(models.Model):
send_private_typing_notifications=bool,
send_read_receipts=bool,
send_stream_typing_notifications=bool,
user_list_style=int,
)
modern_notification_settings: Dict[str, Any] = dict(

View File

@ -8516,6 +8516,23 @@ paths:
- 2
- 3
example: 1
- name: user_list_style
in: query
description: |
The style selected by the user for the right sidebar user list.
- 1 - Compact
- 2 - With status
- 3 - With avatar and status
**Changes**: New in Zulip 6.0 (feature level 141).
schema:
type: integer
enum:
- 1
- 2
- 3
example: 1
- name: enable_stream_desktop_notifications
in: query
description: |
@ -10431,6 +10448,16 @@ paths:
- 1 - Automatic
- 2 - Always
- 3 - Never
user_list_style:
type: integer
description: |
The style selected by the user for the right sidebar user list.
- 1 - Compact
- 2 - With status
- 3 - With avatar and status
**Changes**: New in Zulip 6.0 (feature level 141).
timezone:
type: string
description: |
@ -12344,6 +12371,16 @@ paths:
- 1 - Automatic
- 2 - Always
- 3 - Never
user_list_style:
type: integer
description: |
The style selected by the user for the right sidebar user list.
- 1 - Compact
- 2 - With status
- 3 - With avatar and status
**Changes**: New in Zulip 6.0 (feature level 141).
enable_stream_desktop_notifications:
type: boolean
description: |
@ -13350,6 +13387,23 @@ paths:
- 2
- 3
example: 1
- name: user_list_style
in: query
description: |
The style selected by the user for the right sidebar user list.
- 1 - Compact
- 2 - With status
- 3 - With avatar and status
**Changes**: New in Zulip 6.0 (feature level 141).
schema:
type: integer
enum:
- 1
- 2
- 3
example: 1
- name: timezone
in: query
description: |

View File

@ -2583,6 +2583,7 @@ class RealmPropertyActionTest(BaseAction):
default_view=["recent_topics", "all_messages"],
emojiset=[emojiset["key"] for emojiset in RealmUserDefault.emojiset_choices()],
demote_inactive_streams=UserProfile.DEMOTE_STREAMS_CHOICES,
user_list_style=UserProfile.USER_LIST_STYLE_CHOICES,
desktop_icon_count_display=[1, 2, 3],
notification_sound=["zulip", "ding"],
email_notifications_batching_period_seconds=[120, 300],
@ -2656,6 +2657,7 @@ class UserDisplayActionTest(BaseAction):
default_language=["es", "de", "en"],
default_view=["all_messages", "recent_topics"],
demote_inactive_streams=[2, 3, 1],
user_list_style=[1, 2, 3],
color_scheme=[2, 3, 1],
)

View File

@ -1202,6 +1202,7 @@ class RealmAPITest(ZulipTestCase):
default_view=["recent_topics", "all_messages"],
emojiset=[emojiset["key"] for emojiset in RealmUserDefault.emojiset_choices()],
demote_inactive_streams=UserProfile.DEMOTE_STREAMS_CHOICES,
user_list_style=UserProfile.USER_LIST_STYLE_CHOICES,
desktop_icon_count_display=[1, 2, 3],
notification_sound=["zulip", "ding"],
email_notifications_batching_period_seconds=[120, 300],

View File

@ -357,6 +357,7 @@ class ChangeSettingsTest(ZulipTestCase):
emojiset="google",
timezone="America/Denver",
demote_inactive_streams=2,
user_list_style=2,
color_scheme=2,
email_notifications_batching_period_seconds=100,
notification_sound="ding",
@ -369,7 +370,7 @@ class ChangeSettingsTest(ZulipTestCase):
if test_value is None:
raise AssertionError(f"No test created for {setting_name}")
if setting_name not in ["demote_inactive_streams", "color_scheme"]:
if setting_name not in ["demote_inactive_streams", "user_list_style", "color_scheme"]:
data = {setting_name: test_value}
else:
data = {setting_name: orjson.dumps(test_value).decode()}
@ -395,6 +396,7 @@ class ChangeSettingsTest(ZulipTestCase):
emojiset="apple",
timezone="invalid_US/Mountain",
demote_inactive_streams=10,
user_list_style=10,
color_scheme=10,
notification_sound="invalid_sound",
desktop_icon_count_display=10,

View File

@ -440,6 +440,9 @@ def update_realm_user_settings_defaults(
json_validator=check_bool, default=None
),
send_read_receipts: Optional[bool] = REQ(json_validator=check_bool, default=None),
user_list_style: Optional[int] = REQ(
json_validator=check_int_in(UserProfile.USER_LIST_STYLE_CHOICES), default=None
),
) -> HttpResponse:
if notification_sound is not None or email_notifications_batching_period_seconds is not None:
check_settings_values(notification_sound, email_notifications_batching_period_seconds)

View File

@ -217,6 +217,9 @@ def json_change_settings(
),
send_stream_typing_notifications: Optional[bool] = REQ(json_validator=check_bool, default=None),
send_read_receipts: Optional[bool] = REQ(json_validator=check_bool, default=None),
user_list_style: Optional[int] = REQ(
json_validator=check_int_in(UserProfile.USER_LIST_STYLE_CHOICES), default=None
),
) -> HttpResponse:
if (
default_language is not None