mirror of
https://github.com/zulip/zulip.git
synced 2025-11-06 23:13:25 +00:00
As part of the banner redesign project, this commit applies the new banner styles to the organization profile incomplete navbar banner.
588 lines
21 KiB
TypeScript
588 lines
21 KiB
TypeScript
import {
|
|
differenceInHours,
|
|
differenceInMinutes,
|
|
formatISO,
|
|
isEqual,
|
|
isValid,
|
|
parseISO,
|
|
} from "date-fns";
|
|
import $ from "jquery";
|
|
|
|
import render_markdown_time_tooltip from "../templates/markdown_time_tooltip.hbs";
|
|
|
|
import {$t} from "./i18n.ts";
|
|
import {difference_in_calendar_days, get_offset, start_of_day} from "./time_zone_util.ts";
|
|
import {parse_html} from "./ui_util.ts";
|
|
import {user_settings} from "./user_settings.ts";
|
|
import * as util from "./util.ts";
|
|
|
|
let next_timerender_id = 0;
|
|
|
|
export let display_time_zone = browser_time_zone();
|
|
|
|
const formatter_map = new Map<string, Intl.DateTimeFormat>();
|
|
|
|
export function browser_time_zone(): string {
|
|
return new Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
}
|
|
|
|
export function clear_for_testing(): void {
|
|
next_timerender_id = 0;
|
|
}
|
|
|
|
// Exported for testing only; we do not support live-updating the time zone.
|
|
export function set_display_time_zone(time_zone: string): void {
|
|
display_time_zone = time_zone;
|
|
formatter_map.clear();
|
|
}
|
|
|
|
type DateFormat = "weekday" | "dayofyear" | "weekday_dayofyear_year" | "dayofyear_year";
|
|
type DateWithTimeFormat =
|
|
| "dayofyear_time"
|
|
| "dayofyear_year_time"
|
|
| "weekday_dayofyear_year_time"
|
|
| "full_weekday_dayofyear_year_time";
|
|
type TimeFormat = "time" | "time_sec";
|
|
|
|
type DateOrTimeFormat = DateFormat | TimeFormat | DateWithTimeFormat;
|
|
|
|
// Translates Zulip-specific format names, documented in the comments
|
|
// below, into the appropriate options to pass to the Intl library
|
|
// along with the user's locale to render date in that style of format.
|
|
//
|
|
// Note that because date/time formats vary with locale, the below
|
|
// examples are what a user with English as their language will see
|
|
// but users in other locales will see something different, especially
|
|
// for any formats that display the name for a month/weekday, but
|
|
// possibly in more subtle ways for languages with different
|
|
// punctuation schemes for date and times.
|
|
export function get_format_options_for_type(
|
|
type: DateOrTimeFormat,
|
|
is_twenty_four_hour_time: boolean,
|
|
): Intl.DateTimeFormatOptions {
|
|
const time_format_options: Intl.DateTimeFormatOptions = is_twenty_four_hour_time
|
|
? {hourCycle: "h23", hour: "2-digit", minute: "2-digit"}
|
|
: {
|
|
hourCycle: "h12",
|
|
hour: "numeric",
|
|
minute: "2-digit",
|
|
};
|
|
|
|
const weekday_format_options: Intl.DateTimeFormatOptions = {weekday: "long"};
|
|
const full_format_options: Intl.DateTimeFormatOptions = {
|
|
weekday: "long",
|
|
year: "numeric",
|
|
month: "long",
|
|
day: "numeric",
|
|
};
|
|
|
|
const dayofyear_format_options: Intl.DateTimeFormatOptions = {day: "numeric", month: "short"};
|
|
const dayofyear_year_format_options: Intl.DateTimeFormatOptions = {
|
|
...dayofyear_format_options,
|
|
year: "numeric",
|
|
};
|
|
const long_format_options: Intl.DateTimeFormatOptions = {
|
|
...dayofyear_year_format_options,
|
|
weekday: "short",
|
|
};
|
|
|
|
switch (type) {
|
|
case "time": // 01:30 PM
|
|
return time_format_options;
|
|
case "time_sec": // 01:30:42 PM
|
|
return {...time_format_options, second: "2-digit"};
|
|
case "weekday": // Wednesday
|
|
return weekday_format_options;
|
|
case "dayofyear": // Jul 27
|
|
return dayofyear_format_options;
|
|
case "dayofyear_time": // Jul 27, 01:30 PM
|
|
return {...dayofyear_format_options, ...time_format_options};
|
|
case "dayofyear_year": // Jul 27, 2016
|
|
return dayofyear_year_format_options;
|
|
case "dayofyear_year_time": // Jul 27, 2016, 01:30 PM
|
|
return {...dayofyear_year_format_options, ...time_format_options};
|
|
case "weekday_dayofyear_year": // Wednesday, July 27, 2016
|
|
return full_format_options;
|
|
case "weekday_dayofyear_year_time": // Wed, Jul 27, 2016, 13:30
|
|
return {...long_format_options, ...time_format_options};
|
|
case "full_weekday_dayofyear_year_time": // Wednesday, July 27, 2016, 13:30
|
|
return {...long_format_options, ...time_format_options, weekday: "long", month: "long"};
|
|
default:
|
|
throw new Error("Wrong format provided.");
|
|
}
|
|
}
|
|
|
|
// Common function for all date/time rendering in the project. Handles
|
|
// localization using the user's configured locale and the
|
|
// twenty_four_hour_time setting.
|
|
//
|
|
// See get_format_options_for_type for details on the supported formats.
|
|
export function get_localized_date_or_time_for_format(
|
|
date: Date | number,
|
|
format: DateOrTimeFormat,
|
|
): string {
|
|
const is_twenty_four_hour_time = user_settings.twenty_four_hour_time;
|
|
const format_key = `${user_settings.default_language}:${is_twenty_four_hour_time}:${format}`;
|
|
|
|
if (!formatter_map.has(format_key)) {
|
|
formatter_map.set(
|
|
format_key,
|
|
new Intl.DateTimeFormat(user_settings.default_language, {
|
|
timeZone: display_time_zone,
|
|
...get_format_options_for_type(format, is_twenty_four_hour_time),
|
|
}),
|
|
);
|
|
}
|
|
return formatter_map.get(format_key)!.format(date);
|
|
}
|
|
|
|
// Exported for tests only.
|
|
export function get_tz_with_UTC_offset(time: number | Date): string {
|
|
let timezone = new Intl.DateTimeFormat(user_settings.default_language, {
|
|
timeZone: display_time_zone,
|
|
timeZoneName: "short",
|
|
})
|
|
.formatToParts(time)
|
|
.find(({type}) => type === "timeZoneName")?.value;
|
|
|
|
if (timezone === "UTC") {
|
|
return "UTC";
|
|
}
|
|
|
|
// When user's locale doesn't match their time zone (eg. en_US for IST),
|
|
// we get `timezone` in the format of'GMT+x:y. We don't want to
|
|
// show that along with (UTC+x:y)
|
|
timezone = /GMT[+-][\d:]*/.test(timezone ?? "") ? "" : timezone;
|
|
|
|
const offset_minutes = Math.round(get_offset(time, display_time_zone) / 60000);
|
|
const tz_UTC_offset = `(UTC${offset_minutes < 0 ? "-" : "+"}${String(
|
|
Math.floor(Math.abs(offset_minutes) / 60),
|
|
).padStart(2, "0")}:${String(Math.abs(offset_minutes) % 60).padStart(2, "0")})`;
|
|
|
|
if (timezone) {
|
|
return timezone + " " + tz_UTC_offset;
|
|
}
|
|
return tz_UTC_offset;
|
|
}
|
|
|
|
// Given a Date object 'time', returns an object:
|
|
// {
|
|
// time_str: a string for the current human-formatted version
|
|
// formal_time_str: a string for the current formally formatted version
|
|
// e.g. "Monday, April 15, 2017"
|
|
// needs_update: a boolean for if it will need to be updated when the
|
|
// day changes
|
|
// }
|
|
export type TimeRender = {
|
|
time_str: string;
|
|
formal_time_str: string;
|
|
needs_update: boolean;
|
|
};
|
|
|
|
export let render_now = (time: Date, today = new Date(), display_year?: boolean): TimeRender => {
|
|
let time_str = "";
|
|
let needs_update = false;
|
|
// render formal time to be used for tippy tooltip
|
|
const formal_time_str = get_localized_date_or_time_for_format(time, "weekday_dayofyear_year");
|
|
// How many days old is 'time'? 0 = today, 1 = yesterday, 7 = a
|
|
// week ago, -1 = tomorrow, etc.
|
|
|
|
// Presumably the result of diffDays will be an integer in this
|
|
// case, but round it to be sure before comparing to integer
|
|
// constants.
|
|
const days_old = difference_in_calendar_days(today, time, display_time_zone);
|
|
|
|
if (days_old === 0) {
|
|
time_str = $t({defaultMessage: "Today"});
|
|
needs_update = true;
|
|
} else if (days_old === 1) {
|
|
time_str = $t({defaultMessage: "Yesterday"});
|
|
needs_update = true;
|
|
} else if (time.getFullYear() !== today.getFullYear() || display_year) {
|
|
// For long running servers, searching backlog can get ambiguous
|
|
// without a year stamp. Only show year if message is from an older year
|
|
time_str = get_localized_date_or_time_for_format(time, "dayofyear_year");
|
|
needs_update = false;
|
|
} else {
|
|
// For now, if we get a message from tomorrow, we don't bother
|
|
// rewriting the timestamp when it gets to be tomorrow.
|
|
time_str = get_localized_date_or_time_for_format(time, "dayofyear");
|
|
needs_update = false;
|
|
}
|
|
return {
|
|
time_str,
|
|
formal_time_str,
|
|
needs_update,
|
|
};
|
|
};
|
|
|
|
export function rewire_render_now(value: typeof render_now): void {
|
|
render_now = value;
|
|
}
|
|
|
|
// Relative time rendering for use in most screens like Recent conversations.
|
|
export function relative_time_string_from_date(date: Date): string {
|
|
const current_date = new Date();
|
|
const minutes = differenceInMinutes(current_date, date);
|
|
if (minutes <= 2) {
|
|
return $t({defaultMessage: "Just now"});
|
|
}
|
|
if (minutes < 60) {
|
|
return $t({defaultMessage: "{minutes} minutes ago"}, {minutes});
|
|
}
|
|
|
|
const days_old = difference_in_calendar_days(current_date, date, display_time_zone);
|
|
const hours = Math.floor(minutes / 60);
|
|
|
|
if (hours < 24) {
|
|
if (hours === 1) {
|
|
return $t({defaultMessage: "An hour ago"});
|
|
}
|
|
return $t({defaultMessage: "{hours} hours ago"}, {hours});
|
|
}
|
|
|
|
if (days_old === 1) {
|
|
return $t({defaultMessage: "Yesterday"});
|
|
}
|
|
|
|
if (days_old < 90) {
|
|
return $t({defaultMessage: "{days_old} days ago"}, {days_old});
|
|
} else if (
|
|
days_old > 90 &&
|
|
days_old < 365 &&
|
|
date.getFullYear() === current_date.getFullYear()
|
|
) {
|
|
// Online more than 90 days ago, in the same year
|
|
return get_localized_date_or_time_for_format(date, "dayofyear");
|
|
}
|
|
return get_localized_date_or_time_for_format(date, "dayofyear_year");
|
|
}
|
|
|
|
// Relative time logic variant use in the buddy list, where every
|
|
// string has "Active" init. This is hard to deduplicate with
|
|
// relative_time_string_from_date because of complexities involved in i18n and
|
|
// word order.
|
|
//
|
|
// Current date is passed as an argument for unit testing
|
|
export function last_seen_status_from_date(last_active_date: Date): string {
|
|
const current_date = new Date();
|
|
const minutes = differenceInMinutes(current_date, last_active_date);
|
|
if (minutes < 60) {
|
|
return $t({defaultMessage: "Active {minutes} minutes ago"}, {minutes});
|
|
}
|
|
|
|
const days_old = difference_in_calendar_days(current_date, last_active_date, display_time_zone);
|
|
const hours = Math.floor(minutes / 60);
|
|
|
|
if (hours < 24) {
|
|
if (hours === 1) {
|
|
return $t({defaultMessage: "Active an hour ago"});
|
|
}
|
|
return $t({defaultMessage: "Active {hours} hours ago"}, {hours});
|
|
}
|
|
|
|
if (days_old === 1) {
|
|
return $t({defaultMessage: "Active yesterday"});
|
|
}
|
|
|
|
if (days_old < 90) {
|
|
return $t({defaultMessage: "Active {days_old} days ago"}, {days_old});
|
|
} else if (
|
|
days_old > 90 &&
|
|
days_old < 365 &&
|
|
last_active_date.getFullYear() === current_date.getFullYear()
|
|
) {
|
|
// Online more than 90 days ago, in the same year
|
|
return $t(
|
|
{defaultMessage: "Active {last_active_date}"},
|
|
{
|
|
last_active_date: get_localized_date_or_time_for_format(
|
|
last_active_date,
|
|
"dayofyear",
|
|
),
|
|
},
|
|
);
|
|
}
|
|
return $t(
|
|
{defaultMessage: "Active {last_active_date}"},
|
|
{
|
|
last_active_date: get_localized_date_or_time_for_format(
|
|
last_active_date,
|
|
"dayofyear_year",
|
|
),
|
|
},
|
|
);
|
|
}
|
|
|
|
// List of the dates that need to be updated when the day changes.
|
|
// Each timestamp is represented as a list of length 2:
|
|
// [id of the span element, Date representing the time]
|
|
type UpdateEntry = {
|
|
needs_update: boolean;
|
|
className: string;
|
|
time: Date;
|
|
};
|
|
let update_list: UpdateEntry[] = [];
|
|
|
|
// The time at the beginning of the day, when the timestamps were updated.
|
|
// Represented as a Date with hour, minute, second, millisecond 0.
|
|
let last_update: Date;
|
|
|
|
export function initialize(): void {
|
|
if (
|
|
display_time_zone === undefined || // https://bugs.chromium.org/p/chromium/issues/detail?id=1487920
|
|
display_time_zone === "Etc/Unknown" // https://bugs.chromium.org/p/chromium/issues/detail?id=1473422
|
|
) {
|
|
display_time_zone = user_settings.timezone;
|
|
try {
|
|
new Intl.DateTimeFormat(undefined, {timeZone: display_time_zone});
|
|
} catch {
|
|
display_time_zone = "UTC";
|
|
}
|
|
}
|
|
|
|
last_update = start_of_day(new Date(), display_time_zone);
|
|
}
|
|
|
|
function maybe_add_update_list_entry(entry: UpdateEntry): void {
|
|
if (entry.needs_update) {
|
|
update_list.push(entry);
|
|
}
|
|
}
|
|
|
|
function render_date_span($elem: JQuery, rendered_time: TimeRender): JQuery {
|
|
$elem.text(rendered_time.time_str);
|
|
return $elem.attr("data-tippy-content", rendered_time.formal_time_str);
|
|
}
|
|
|
|
// Given an Date object 'time', return a DOM node that initially
|
|
// displays the human-formatted date, and is updated automatically as
|
|
// necessary (e.g. changing "Today" to "Yesterday" to "Jul 1").
|
|
|
|
// (What's actually spliced into the message template is the contents
|
|
// of this DOM node as HTML, so effectively a copy of the node. That's
|
|
// okay since to update the time later we look up the node by its id.)
|
|
export function render_date(time: Date, display_year?: boolean): HTMLElement {
|
|
const className = `timerender${next_timerender_id}`;
|
|
next_timerender_id += 1;
|
|
const today = new Date();
|
|
const rendered_time = render_now(time, today, display_year);
|
|
let $node = $("<span>").attr("class", `timerender-content ${className}`);
|
|
$node = render_date_span($node, rendered_time);
|
|
maybe_add_update_list_entry({
|
|
needs_update: rendered_time.needs_update,
|
|
className,
|
|
time,
|
|
});
|
|
return util.the($node);
|
|
}
|
|
|
|
// Renders the timestamp returned by the <time:> Markdown syntax.
|
|
export function format_markdown_time(time: number | Date): string {
|
|
return get_localized_date_or_time_for_format(time, "weekday_dayofyear_year_time");
|
|
}
|
|
|
|
export function get_markdown_time_tooltip(reference: HTMLElement): DocumentFragment | string {
|
|
if (reference instanceof HTMLTimeElement) {
|
|
const time = parseISO(reference.dateTime);
|
|
const tz_offset_str = get_tz_with_UTC_offset(time);
|
|
return parse_html(render_markdown_time_tooltip({tz_offset_str}));
|
|
}
|
|
return "";
|
|
}
|
|
|
|
// This isn't expected to be called externally except manually for
|
|
// testing purposes.
|
|
export function update_timestamps(): void {
|
|
const today = start_of_day(new Date(), display_time_zone);
|
|
if (!isEqual(today, last_update)) {
|
|
const to_process = update_list;
|
|
update_list = [];
|
|
|
|
for (const entry of to_process) {
|
|
const className = entry.className;
|
|
const $elements = $(`.${CSS.escape(className)}`);
|
|
// The element might not exist any more (because it
|
|
// was in the narrowed message list which was removed,
|
|
// or because we added messages above it and re-collapsed).
|
|
if ($elements.length > 0) {
|
|
const time = entry.time;
|
|
const rendered_time = render_now(time, today);
|
|
for (const element of $elements) {
|
|
render_date_span($(element), rendered_time);
|
|
}
|
|
maybe_add_update_list_entry({
|
|
needs_update: rendered_time.needs_update,
|
|
className,
|
|
time,
|
|
});
|
|
}
|
|
}
|
|
|
|
last_update = today;
|
|
}
|
|
}
|
|
|
|
setInterval(update_timestamps, 60 * 1000);
|
|
|
|
// Transform a Unix timestamp into a ISO 8601 formatted date string.
|
|
// Example: 1978-10-31T13:37:42Z
|
|
export function get_full_time(timestamp: number): string {
|
|
return formatISO(timestamp * 1000);
|
|
}
|
|
|
|
function get_current_time_to_hour(): Date {
|
|
const timestamp = new Date();
|
|
timestamp.setMinutes(0, 0);
|
|
return timestamp;
|
|
}
|
|
|
|
export function get_timestamp_for_flatpickr(timestring?: string): Date {
|
|
let timestamp;
|
|
|
|
// timestring is undefined when first opening the picker from the
|
|
// compose box button.
|
|
if (timestring === undefined) {
|
|
return get_current_time_to_hour();
|
|
}
|
|
|
|
try {
|
|
// If there's already a valid time in the compose box,
|
|
// we use it to initialize the flatpickr instance.
|
|
timestamp = parseISO(timestring);
|
|
} finally {
|
|
// Otherwise, default to showing the current time to the hour.
|
|
if (!timestamp || !isValid(timestamp)) {
|
|
timestamp = get_current_time_to_hour();
|
|
}
|
|
}
|
|
return timestamp;
|
|
}
|
|
|
|
export function stringify_time(time: number | Date): string {
|
|
return get_localized_date_or_time_for_format(time, "time");
|
|
}
|
|
|
|
export function format_time_modern(time: number | Date, today = new Date()): string {
|
|
const hours = differenceInHours(today, time);
|
|
const days_old = difference_in_calendar_days(today, time, display_time_zone);
|
|
|
|
if (time > today) {
|
|
/* For timestamps in the future, we always show the year*/
|
|
return get_localized_date_or_time_for_format(time, "dayofyear_year");
|
|
} else if (hours < 24) {
|
|
return stringify_time(time);
|
|
} else if (days_old === 1) {
|
|
return $t({defaultMessage: "Yesterday"});
|
|
} else if (days_old < 7) {
|
|
return get_localized_date_or_time_for_format(time, "weekday");
|
|
} else if (days_old <= 180) {
|
|
return get_localized_date_or_time_for_format(time, "dayofyear");
|
|
}
|
|
|
|
return get_localized_date_or_time_for_format(time, "dayofyear_year");
|
|
}
|
|
|
|
// this is for rendering absolute time based off the preferences for twenty-four
|
|
// hour time in the format of "%mmm %d, %h:%m %p".
|
|
export function absolute_time(timestamp: number): string {
|
|
const today = new Date();
|
|
const date = new Date(timestamp);
|
|
const is_older_year = today.getFullYear() - date.getFullYear() > 0;
|
|
|
|
return get_localized_date_or_time_for_format(
|
|
date,
|
|
is_older_year ? "dayofyear_year_time" : "dayofyear_time",
|
|
);
|
|
}
|
|
|
|
// Pass time_format="time" to not include seconds in the time format.
|
|
export function get_full_datetime(time: Date, time_format: TimeFormat = "time_sec"): string {
|
|
const date_string = get_localized_date_or_time_for_format(time, "dayofyear_year");
|
|
const time_string = get_localized_date_or_time_for_format(time, time_format);
|
|
return $t({defaultMessage: "{date} at {time}"}, {date: date_string, time: time_string});
|
|
}
|
|
|
|
// Preferred variant for displaying a full datetime to users in
|
|
// contexts like tooltips, where the time was already displayed to the
|
|
// user in a less precise format.
|
|
export function get_full_datetime_clarification(
|
|
time: Date,
|
|
time_format: TimeFormat = "time_sec",
|
|
): string {
|
|
const date_string = time.toLocaleDateString(user_settings.default_language, {
|
|
timeZone: display_time_zone,
|
|
});
|
|
let time_string = get_localized_date_or_time_for_format(time, time_format);
|
|
|
|
const tz_offset_str = get_tz_with_UTC_offset(time);
|
|
|
|
time_string = time_string + " " + tz_offset_str;
|
|
|
|
return $t({defaultMessage: "{date} at {time}"}, {date: date_string, time: time_string});
|
|
}
|
|
|
|
type TimeLimitSetting = {
|
|
value: number;
|
|
unit: string;
|
|
};
|
|
|
|
export function get_time_limit_setting_in_appropriate_unit(
|
|
time_limit_in_seconds: number,
|
|
): TimeLimitSetting {
|
|
const time_limit_in_minutes = Math.floor(time_limit_in_seconds / 60);
|
|
if (time_limit_in_minutes < 60) {
|
|
return {value: time_limit_in_minutes, unit: "minute"};
|
|
}
|
|
|
|
const time_limit_in_hours = Math.floor(time_limit_in_minutes / 60);
|
|
if (time_limit_in_hours < 24) {
|
|
return {value: time_limit_in_hours, unit: "hour"};
|
|
}
|
|
|
|
const time_limit_in_days = Math.floor(time_limit_in_hours / 24);
|
|
return {value: time_limit_in_days, unit: "day"};
|
|
}
|
|
|
|
export function get_time_in_timezone(date: Date, timezone: string): number {
|
|
return Date.parse(date.toLocaleString("en-US", {timeZone: timezone}));
|
|
}
|
|
|
|
export function get_offset_difference_at_date(
|
|
timezone1: string,
|
|
timezone2: string,
|
|
reference_date: Date,
|
|
): number {
|
|
const date1 = get_time_in_timezone(reference_date, timezone1);
|
|
const date2 = get_time_in_timezone(reference_date, timezone2);
|
|
return date1 - date2;
|
|
}
|
|
|
|
export function are_timezones_on_same_clock_now(timezone1: string, timezone2: string): boolean {
|
|
// America/Los_Angeles is clearly the same as America/Los_Angeles:
|
|
if (timezone1 === timezone2) {
|
|
return true;
|
|
}
|
|
|
|
// We still want this function to return true if the timezones are
|
|
// on the same clock for now, even though they may eventually diverge
|
|
// during Daylight Savings. This avoids nagging the user. The only
|
|
// tradeoff is if the user stays logged on while the clocks change,
|
|
// but that should be rare.
|
|
const now = new Date();
|
|
|
|
try {
|
|
return get_offset_difference_at_date(timezone1, timezone2, now) === 0;
|
|
} catch {
|
|
// This should only happen during testing, but we just catch any error
|
|
// related to invalid time zones.
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function is_browser_timezone_same_as(zulip_time_zone: string): boolean {
|
|
// We delegate most of this check to facilitate testing.
|
|
// We don't want to mock browser_time_zone.
|
|
return are_timezones_on_same_clock_now(browser_time_zone(), zulip_time_zone);
|
|
}
|