zulip/web/src/timerender.ts

553 lines
20 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 = new Intl.DateTimeFormat().resolvedOptions().timeZone;
const formatter_map = new Map<string, Intl.DateTimeFormat>();
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 should_display_profile_incomplete_alert(timestamp: number): boolean {
const today = new Date(Date.now());
const time = new Date(timestamp * 1000);
const days_old = difference_in_calendar_days(today, time, display_time_zone);
if (days_old >= 15) {
return true;
}
return false;
}