diff --git a/docs/THIRDPARTY b/docs/THIRDPARTY index fac9af94d5..b21502f51b 100644 --- a/docs/THIRDPARTY +++ b/docs/THIRDPARTY @@ -268,6 +268,11 @@ Source: https://lucide.dev/icons/clock-10 Copyright: 2013-2022 Cole Bemis License: ISC License +Files: web/shared/icons/plus.svg +Source: https://lucide.dev/icons/plus +Copyright: 2013-2022 Cole Bemis +License: ISC License + Files: web/third/bootstrap/css/bootstrap.app.css Copyright: 2012 Twitter, Inc. License: Apache-2.0 diff --git a/web/shared/icons/line-height-big.svg b/web/shared/icons/line-height-big.svg new file mode 100644 index 0000000000..4e1bdcfdc8 --- /dev/null +++ b/web/shared/icons/line-height-big.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/web/shared/icons/minus.svg b/web/shared/icons/minus.svg new file mode 100644 index 0000000000..37dfff8906 --- /dev/null +++ b/web/shared/icons/minus.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/shared/icons/plus.svg b/web/shared/icons/plus.svg new file mode 100644 index 0000000000..9d4d386d7c --- /dev/null +++ b/web/shared/icons/plus.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/shared/icons/type-big.svg b/web/shared/icons/type-big.svg new file mode 100644 index 0000000000..f3f8f85b9e --- /dev/null +++ b/web/shared/icons/type-big.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/src/information_density.ts b/web/src/information_density.ts index 4db2ae21c0..c0c980a2b9 100644 --- a/web/src/information_density.ts +++ b/web/src/information_density.ts @@ -1,5 +1,8 @@ import $ from "jquery"; +import assert from "minimalistic-assert"; +import {z} from "zod"; +import {$t} from "./i18n.ts"; import * as resize from "./resize.ts"; import {stringify_time} from "./timerender.ts"; import {user_settings} from "./user_settings.ts"; @@ -49,6 +52,35 @@ export const LEGACY_LINE_HEIGHT_PERCENT = 122; export const NON_COMPACT_MODE_FONT_SIZE_PX = 16; export const NON_COMPACT_MODE_LINE_HEIGHT_PERCENT = 140; +export const INFO_DENSITY_VALUES_DICT = { + web_font_size_px: { + default: NON_COMPACT_MODE_FONT_SIZE_PX, + minimum: 12, + maximum: 20, + // by how much the value will be changed on clicking +/- buttons. + step_value: 1, + }, + web_line_height_percent: { + default: NON_COMPACT_MODE_LINE_HEIGHT_PERCENT, + minimum: 122, + maximum: 158, + // by how much the value will be changed on clicking +/- buttons. + step_value: 9, + }, +}; + +// TODO: Compute these from INFO_DENSITY_VALUES_DICT, rather than repeating it. +const line_height_supported_values = [122, 131, 140, 149, 158]; + +export const MIN_VALUES = { + web_font_size_px: 12, + web_line_height_percent: 122, +}; +export const MAX_VALUES = { + web_font_size_px: 20, + web_line_height_percent: 158, +}; + function set_vertical_alignment_values(line_height_unitless: number): void { // We work in ems to keep this agnostic to the font size. const line_height_in_ems = line_height_unitless; @@ -173,3 +205,267 @@ export function initialize(): void { calculate_timestamp_widths(); determine_container_query_support(); } + +export const information_density_properties_schema = z.enum([ + "web_font_size_px", + "web_line_height_percent", +]); + +export function enable_or_disable_control_buttons($container: JQuery): void { + const info_density_properties = z + .array(information_density_properties_schema) + .parse(["web_font_size_px", "web_line_height_percent"]); + for (const property of info_density_properties) { + const $button_group = $container.find(`[data-property='${CSS.escape(property)}']`); + const $current_elem = $button_group.find(".current-value"); + const current_value = Number.parseInt($current_elem.val()!, 10); + + $button_group + .find(".default-button") + .prop("disabled", current_value === INFO_DENSITY_VALUES_DICT[property].default); + $button_group + .find(".increase-button") + .prop("disabled", current_value >= INFO_DENSITY_VALUES_DICT[property].maximum); + $button_group + .find(".decrease-button") + .prop("disabled", current_value <= INFO_DENSITY_VALUES_DICT[property].minimum); + } +} + +export function find_new_supported_value_for_setting( + $elem: JQuery, + property: "web_font_size_px" | "web_line_height_percent", + current_value: number, +): number { + if (current_value > INFO_DENSITY_VALUES_DICT[property].maximum) { + return INFO_DENSITY_VALUES_DICT[property].maximum; + } + + if (current_value < INFO_DENSITY_VALUES_DICT[property].minimum) { + return INFO_DENSITY_VALUES_DICT[property].minimum; + } + + // We know the value is inside the range of valid values, but not + // a recommended value. This is only possible with line height, + // where we allow any integer in the database, but only offer + // certain steps in the UI. + assert(property === "web_line_height_percent"); + + if ($elem.hasClass("increase-button")) { + return line_height_supported_values.find((valid_value) => valid_value > current_value)!; + } + + return line_height_supported_values.findLast((valid_value) => valid_value < current_value)!; +} + +export function check_setting_has_recommended_value( + property: "web_font_size_px" | "web_line_height_percent", + current_value: number, +): boolean { + if (current_value > INFO_DENSITY_VALUES_DICT[property].maximum) { + return false; + } + + if (current_value < INFO_DENSITY_VALUES_DICT[property].minimum) { + return false; + } + + if (property === "web_font_size_px") { + return true; + } + + return line_height_supported_values.includes(current_value); +} + +export function get_new_value_for_information_density_settings( + $elem: JQuery, + changed_property: "web_font_size_px" | "web_line_height_percent", +): number { + const $current_elem = $elem.closest(".button-group").find(".current-value"); + const current_value = Number.parseInt($current_elem.val()!, 10); + + if ($elem.hasClass("default-button")) { + return INFO_DENSITY_VALUES_DICT[changed_property].default; + } + + if (!check_setting_has_recommended_value(changed_property, current_value)) { + return find_new_supported_value_for_setting($elem, changed_property, current_value); + } + + if ($elem.hasClass("increase-button")) { + return current_value + INFO_DENSITY_VALUES_DICT[changed_property].step_value; + } + + return current_value - INFO_DENSITY_VALUES_DICT[changed_property].step_value; +} + +export function update_information_density_settings( + $elem: JQuery, + changed_property: "web_font_size_px" | "web_line_height_percent", +): number { + const new_value = get_new_value_for_information_density_settings($elem, changed_property); + + user_settings[changed_property] = new_value; + $elem.closest(".button-group").find(".current-value").val(new_value); + + set_base_typography_css_variables(); + calculate_timestamp_widths(); + + return new_value; +} + +export function get_string_display_value_for_line_height(setting_value: number): string { + const step_count = + (setting_value - NON_COMPACT_MODE_LINE_HEIGHT_PERCENT) / + INFO_DENSITY_VALUES_DICT.web_line_height_percent.step_value; + let display_value; + + if (step_count % 1 === 0) { + // If value is an integer, we just return here to avoid showing + // 1.0 for 1. + display_value = step_count.toString(); + } else { + display_value = step_count.toFixed(1); + } + + if (step_count > 0) { + // We want to show "1" as "+1". + return "+" + display_value; + } + return display_value; +} + +export function get_tooltip_context_for_info_density_buttons( + $elem: JQuery, +): Record { + const property = information_density_properties_schema.parse( + $elem.closest(".button-group").attr("data-property"), + ); + + const is_default_button = $elem.hasClass("default-button"); + const new_value = get_new_value_for_information_density_settings($elem, property); + const default_value = INFO_DENSITY_VALUES_DICT[property].default; + const current_value = Number.parseInt( + $elem.closest(".button-group").find(".current-value").val()!, + 10, + ); + const is_current_value_default = current_value === default_value; + + let tooltip_first_line = ""; + let tooltip_second_line = ""; + if (property === "web_font_size_px") { + if (is_default_button) { + if (is_current_value_default) { + tooltip_first_line = $t( + {defaultMessage: "Already at default font size ({default_value}pt)"}, + {default_value}, + ); + } else { + tooltip_first_line = $t( + {defaultMessage: "Reset to default font size ({default_value}pt)"}, + {default_value}, + ); + tooltip_second_line = $t( + {defaultMessage: "Current font size: {current_value}pt"}, + {current_value}, + ); + } + } else if (!$elem.prop("disabled")) { + tooltip_first_line = $t( + {defaultMessage: "Change to {new_value}pt font size"}, + {new_value}, + ); + } else { + if ($elem.hasClass("increase-button")) { + const maximum_value = INFO_DENSITY_VALUES_DICT[property].maximum; + if (current_value === maximum_value) { + tooltip_first_line = $t( + {defaultMessage: "Already at maximum font size ({maximum_value}pt)"}, + {maximum_value}, + ); + } else { + tooltip_first_line = $t( + { + defaultMessage: + "Already above recommended maximum font size ({maximum_value}pt)", + }, + {maximum_value}, + ); + } + } else { + const minimum_value = INFO_DENSITY_VALUES_DICT[property].minimum; + if (current_value === minimum_value) { + tooltip_first_line = $t( + {defaultMessage: "Already at minimum font size ({minimum_value}pt)"}, + {minimum_value}, + ); + } else { + tooltip_first_line = $t( + { + defaultMessage: + "Already below recommended minimum font size ({minimum_value}pt)", + }, + {minimum_value}, + ); + } + } + } + } + + if (property === "web_line_height_percent") { + if (is_default_button) { + if (is_current_value_default) { + tooltip_first_line = $t({defaultMessage: "Already at default line spacing"}); + } else { + const current_value_string = + get_string_display_value_for_line_height(current_value); + tooltip_first_line = $t({defaultMessage: "Reset to default line spacing"}); + tooltip_second_line = $t( + {defaultMessage: "Current line spacing: {current_value_string}"}, + {current_value_string}, + ); + } + } else { + if (!$elem.prop("disabled")) { + if (new_value === default_value) { + tooltip_first_line = $t({defaultMessage: "Change to default line spacing"}); + } else { + const new_value_string = get_string_display_value_for_line_height(new_value); + tooltip_first_line = $t( + {defaultMessage: "Change to {new_value_string} line spacing"}, + {new_value_string}, + ); + } + } else { + if ($elem.hasClass("increase-button")) { + const maximum_value = INFO_DENSITY_VALUES_DICT[property].maximum; + if (current_value === maximum_value) { + tooltip_first_line = $t({ + defaultMessage: "Already at maximum line spacing", + }); + } else { + tooltip_first_line = $t({ + defaultMessage: "Already above recommended maximum line spacing", + }); + } + } else { + const minimum_value = INFO_DENSITY_VALUES_DICT[property].minimum; + if (current_value === minimum_value) { + tooltip_first_line = $t({ + defaultMessage: "Already at minimum line spacing", + }); + } else { + tooltip_first_line = $t({ + defaultMessage: "Already below recommended minimum line spacing", + }); + } + } + } + } + } + + return { + tooltip_first_line, + tooltip_second_line, + }; +} diff --git a/web/src/personal_menu_popover.ts b/web/src/personal_menu_popover.ts index 53406eaeef..2789060da5 100644 --- a/web/src/personal_menu_popover.ts +++ b/web/src/personal_menu_popover.ts @@ -3,6 +3,7 @@ import $ from "jquery"; import render_navbar_personal_menu_popover from "../templates/popovers/navbar/navbar_personal_menu_popover.hbs"; import * as channel from "./channel.ts"; +import * as information_density from "./information_density.ts"; import * as message_view from "./message_view.ts"; import * as people from "./people.ts"; import * as popover_menus from "./popover_menus.ts"; @@ -105,6 +106,48 @@ export function initialize(): void { popovers.hide_all(); e.preventDefault(); }); + + $popper.on("click", ".info-density-controls button", function (this: HTMLElement, e) { + const changed_property = + information_density.information_density_properties_schema.parse( + $(this).closest(".button-group").attr("data-property"), + ); + const new_setting_value = information_density.update_information_density_settings( + $(this), + changed_property, + ); + const data: Record = {}; + data[changed_property] = new_setting_value; + information_density.enable_or_disable_control_buttons($popper); + void channel.patch({ + url: "/json/settings", + data, + // We don't declare success or error + // handlers. We've already locally echoed the + // change, and the thinking for this component is + // that right answer for error handling is to do + // nothing. For the offline case, just letting you + // adjust the font size locally is great, and it's + // not obvious what good error handling is here + // for the server being down other than "try again + // later", which might as well be your next + // session. + // + // This strategy also avoids unpleasant races + // involving the button being clicked several + // times in quick succession. + }); + e.preventDefault(); + }); + + const resizeObserver = new ResizeObserver(() => { + requestAnimationFrame(() => { + void instance.popperInstance?.update(); + }); + }); + resizeObserver.observe(document.querySelector("#personal-menu-dropdown")!); + + information_density.enable_or_disable_control_buttons($popper); void instance.popperInstance?.update(); }, onShow(instance) { diff --git a/web/src/popover_menus_data.ts b/web/src/popover_menus_data.ts index b1d5a923b4..9507745a6a 100644 --- a/web/src/popover_menus_data.ts +++ b/web/src/popover_menus_data.ts @@ -97,6 +97,8 @@ type PersonalMenuContext = { status_emoji_info: UserStatusEmojiInfo | undefined; user_color_scheme: number; color_scheme_values: ColorSchemeValues; + web_font_size_px: number; + web_line_height_percent: number; }; type GearMenuContext = { @@ -337,6 +339,10 @@ export function get_personal_menu_content_context(): PersonalMenuContext { // user color scheme user_color_scheme: user_settings.color_scheme, color_scheme_values: settings_config.color_scheme_values, + + // info density values + web_font_size_px: user_settings.web_font_size_px, + web_line_height_percent: user_settings.web_line_height_percent, }; } diff --git a/web/src/server_events_dispatch.js b/web/src/server_events_dispatch.js index e375e83094..c9ea3cc8ff 100644 --- a/web/src/server_events_dispatch.js +++ b/web/src/server_events_dispatch.js @@ -821,7 +821,6 @@ export function dispatch_normal_event(event) { "color_scheme", "default_language", "demote_inactive_streams", - "dense_mode", "display_emoji_reaction_users", "emojiset", "enter_sends", @@ -837,9 +836,7 @@ export function dispatch_normal_event(event) { "web_animate_image_previews", "web_channel_default_view", "web_escape_navigates_to_home_view", - "web_font_size_px", "web_home_view", - "web_line_height_percent", "web_mark_read_on_scroll_policy", "web_navigate_to_sent_message", "web_stream_unreads_count_display_policy", @@ -904,19 +901,19 @@ export function dispatch_normal_event(event) { ); activity_ui.build_user_sidebar(); } - if (event.property === "dense_mode") { - $("body").toggleClass("less-dense-mode"); - $("body").toggleClass("more-dense-mode"); - information_density.set_base_typography_css_variables(); - information_density.calculate_timestamp_widths(); - } if ( + event.property === "dense_mode" || event.property === "web_font_size_px" || event.property === "web_line_height_percent" ) { - information_density.set_base_typography_css_variables(); - information_density.calculate_timestamp_widths(); + // We just ignore events for "dense_mode", "web_font_size_px" + // and "web_line_height_percent" settings as we are fine + // with a window not being updated due to changes being done + // from another window and also helps in avoiding weird issues + // on clicking the "+"/"-" buttons multiple times quickly when + // updating these settings. } + if (event.property === "web_mark_read_on_scroll_policy") { unread_ui.update_unread_banner(); } diff --git a/web/src/tippyjs.ts b/web/src/tippyjs.ts index de1423e2c6..c1e3ad5143 100644 --- a/web/src/tippyjs.ts +++ b/web/src/tippyjs.ts @@ -4,10 +4,12 @@ import * as tippy from "tippy.js"; import render_buddy_list_title_tooltip from "../templates/buddy_list/title_tooltip.hbs"; import render_change_visibility_policy_button_tooltip from "../templates/change_visibility_policy_button_tooltip.hbs"; +import render_information_density_update_button_tooltip from "../templates/information_density_update_button_tooltip.hbs"; import render_org_logo_tooltip from "../templates/org_logo_tooltip.hbs"; import render_tooltip_templates from "../templates/tooltip_templates.hbs"; import {$t} from "./i18n.ts"; +import * as information_density from "./information_density.ts"; import * as people from "./people.ts"; import * as settings_config from "./settings_config.ts"; import * as stream_data from "./stream_data.ts"; @@ -702,4 +704,28 @@ export function initialize(): void { instance.destroy(); }, }); + + tippy.delegate("body", { + target: "#personal-menu-dropdown .info-density-button-container", + delay: LONG_HOVER_DELAY, + appendTo: () => document.body, + placement: "bottom", + onShow(instance) { + const button_container = instance.reference; + assert(button_container instanceof HTMLElement); + + const tooltip_context = + information_density.get_tooltip_context_for_info_density_buttons( + $(button_container).find(".info-density-button"), + ); + instance.setContent( + ui_util.parse_html( + render_information_density_update_button_tooltip(tooltip_context), + ), + ); + }, + onHidden(instance) { + instance.destroy(); + }, + }); } diff --git a/web/styles/app_components.css b/web/styles/app_components.css index 9f1d4b9731..c125683071 100644 --- a/web/styles/app_components.css +++ b/web/styles/app_components.css @@ -1434,3 +1434,65 @@ input.invalid-input { text-overflow: ellipsis; overflow: hidden; } + +.info-density-controls { + .button-group { + border-radius: 5px; + border: 1px solid var(--color-info-density-control-border); + width: 100%; + display: grid; + grid-auto-flow: column; + + .info-density-button-container { + display: inline-flex; + flex-direction: column; + + &:first-of-type { + .info-density-button { + border-top-left-radius: 5px; + border-bottom-left-radius: 5px; + } + } + + &:last-of-type { + .info-density-button { + border-top-right-radius: 5px; + border-bottom-right-radius: 5px; + } + } + } + + .info-density-button { + border: none; + background-color: var(--color-background-popover); + padding: 0.25em 0.4375em; + display: inline-flex; + align-items: center; + vertical-align: unset; + justify-content: center; + margin: 0; + + &:hover:not(:disabled) { + background-color: var( + --color-info-density-button-hover-background + ); + } + + &:focus { + outline: none; + } + + &:disabled { + cursor: default; + opacity: 0.4; + } + + .zulip-icon { + display: flex; + align-items: center; + color: var(--color-info-denisty-button-icon); + opacity: 0.7; + } + } + } +} diff --git a/web/styles/app_variables.css b/web/styles/app_variables.css index aa6df3c318..ae101548b6 100644 --- a/web/styles/app_variables.css +++ b/web/styles/app_variables.css @@ -2523,6 +2523,20 @@ hsl(0deg 35% 92%), hsl(0deg 52% 18%) ); + + /* Info density update UI */ + --color-info-density-control-border: light-dark( + hsl(0deg 0% 0% / 20%), + hsl(0deg 0% 90% / 20%) + ); + --color-info-denisty-button-icon: light-dark( + hsl(229deg 9% 36%), + hsl(0deg 0% 100% / 80%) + ); + --color-info-density-button-hover-background: light-dark( + hsl(229deg 9% 36% / 7%), + hsl(229deg 10% 50% / 30%) + ); } %dark-theme { diff --git a/web/styles/dark_theme.css b/web/styles/dark_theme.css index 68995522fe..5b8ac197f5 100644 --- a/web/styles/dark_theme.css +++ b/web/styles/dark_theme.css @@ -284,7 +284,7 @@ /* these are converting grey things to "new grey". :disabled rules are exploded for CSS selector performance reasons. */ - button:disabled:not(.action-button, .icon-button), + button:disabled:not(.action-button, .icon-button, .info-density-button), option:disabled, select:disabled, textarea:disabled, @@ -298,6 +298,11 @@ opacity: 0.5; } + button.info-density-button:disabled { + color: inherit; + opacity: 0.4; + } + .rendered_markdown .message_inline_image { background: hsl(0deg 0% 100% / 3%); diff --git a/web/styles/popovers.css b/web/styles/popovers.css index 995bf7156b..cded53a735 100644 --- a/web/styles/popovers.css +++ b/web/styles/popovers.css @@ -1275,6 +1275,17 @@ ul.popover-group-menu-member-list { position: relative; top: -1px; } + + .info-density-controls { + display: flex; + padding: 0.125em 0.625em; + gap: 0.5em; + + .zulip-icon { + width: 0.933em; + height: 0.933em; + } + } } #help-menu-dropdown, diff --git a/web/templates/information_density_update_button_tooltip.hbs b/web/templates/information_density_update_button_tooltip.hbs new file mode 100644 index 0000000000..7d866c04a8 --- /dev/null +++ b/web/templates/information_density_update_button_tooltip.hbs @@ -0,0 +1,11 @@ +
+
+ + {{tooltip_first_line}} + {{#if tooltip_second_line}} +
+ {{tooltip_second_line}} + {{/if}} +
+
+
diff --git a/web/templates/popovers/navbar/navbar_personal_menu_popover.hbs b/web/templates/popovers/navbar/navbar_personal_menu_popover.hbs index eeedea9e34..0d94a8c264 100644 --- a/web/templates/popovers/navbar/navbar_personal_menu_popover.hbs +++ b/web/templates/popovers/navbar/navbar_personal_menu_popover.hbs @@ -115,6 +115,24 @@ +
  • +
    + {{> ../../settings/info_density_control_button_group + property="web_font_size_px" + default_icon_class="zulip-icon-type-big" + property_value=web_font_size_px + for_settings_ui=false + prefix="personal_menu_" + }} + {{> ../../settings/info_density_control_button_group + property="web_line_height_percent" + default_icon_class="zulip-icon-line-height-big" + property_value=web_line_height_percent + for_settings_ui=false + prefix="personal_menu_" + }} +
    +
  • {{!-- Group 4 --}}