zulip/static/js/timerender.ts

362 lines
12 KiB
TypeScript
Raw Normal View History

import {
differenceInCalendarDays,
differenceInHours,
differenceInMinutes,
format,
formatISO,
isEqual,
isValid,
parseISO,
startOfToday,
} from "date-fns";
import $ from "jquery";
import _ from "lodash";
import render_markdown_time_tooltip from "../templates/markdown_time_tooltip.hbs";
import {$t} from "./i18n";
import {parse_html} from "./ui_util";
import {user_settings} from "./user_settings";
let next_timerender_id = 0;
export function clear_for_testing(): void {
next_timerender_id = 0;
}
// Exported for tests only.
export function get_tz_with_UTC_offset(time: number | Date): string {
const tz_offset = format(time, "xxx");
let timezone = new Intl.DateTimeFormat(undefined, {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 tz_UTC_offset = `(UTC${tz_offset})`;
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 function render_now(time: Date, today = new Date()): TimeRender {
let time_str = "";
let needs_update = false;
// render formal time to be used for tippy tooltip
// "\xa0" is U+00A0 NO-BREAK SPACE.
// Can't use   as that represents the literal string " ".
const formal_time_str = format(time, "EEEE,\u00A0MMMM\u00A0d,\u00A0yyyy");
// 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 = differenceInCalendarDays(today, time);
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()) {
// 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 = format(time, "MMM\u00A0dd,\u00A0yyyy");
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 = format(time, "MMM\u00A0dd");
needs_update = false;
}
return {
time_str,
formal_time_str,
needs_update,
};
}
// Current date is passed as an argument for unit testing
export function last_seen_status_from_date(
last_active_date: Date,
current_date = new Date(),
): string {
const minutes = differenceInMinutes(current_date, last_active_date);
if (minutes <= 2) {
return $t({defaultMessage: "Just now"});
}
if (minutes < 60) {
return $t({defaultMessage: "{minutes} minutes ago"}, {minutes});
}
const days_old = differenceInCalendarDays(current_date, last_active_date);
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 &&
last_active_date.getFullYear() === current_date.getFullYear()
) {
// Online more than 90 days ago, in the same year
return $t(
{defaultMessage: "{last_active_date}"},
{last_active_date: format(last_active_date, "MMM\u00A0dd")},
);
}
return $t(
{defaultMessage: "{last_active_date}"},
{last_active_date: format(last_active_date, "MMM\u00A0dd,\u00A0yyyy")},
);
}
// 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;
time_above?: 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 {
last_update = startOfToday();
}
// time_above is an optional argument, to support dates that look like:
// --- ▲ Yesterday ▲ ------ ▼ Today ▼ ---
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,
rendered_time_above?: TimeRender,
): JQuery {
$elem.text("");
if (rendered_time_above !== undefined) {
$elem.append(
'<i class="date-direction fa fa-caret-up"></i>',
_.escape(rendered_time_above.time_str),
'<hr class="date-line">',
'<i class="date-direction fa fa-caret-down"></i>',
_.escape(rendered_time.time_str),
);
return $elem;
}
$elem.append(_.escape(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").
// If two dates are given, it renders them as:
// --- ▲ Yesterday ▲ ------ ▼ Today ▼ ---
// (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, time_above: Date | undefined, today: Date): JQuery {
const className = `timerender${next_timerender_id}`;
next_timerender_id += 1;
const rendered_time = render_now(time, today);
let $node = $("<span />").attr("class", className);
if (time_above !== undefined) {
const rendered_time_above = render_now(time_above, today);
$node = render_date_span($node, rendered_time, rendered_time_above);
} else {
$node = render_date_span($node, rendered_time);
}
maybe_add_update_list_entry({
needs_update: rendered_time.needs_update,
className,
time,
time_above,
});
return $node;
}
// Renders the timestamp returned by the <time:> Markdown syntax.
export function format_markdown_time(time: number | Date): string {
const hourformat = user_settings.twenty_four_hour_time ? "HH:mm" : "h:mm a";
return format(time, "E, MMM d yyyy, " + hourformat);
}
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 = startOfToday();
if (!isEqual(today, last_update)) {
const to_process = update_list;
update_list = [];
js: Automatically convert _.each to for…of. This commit was automatically generated by the following script, followed by lint --fix and a few small manual lint-related cleanups. import * as babelParser from "recast/parsers/babel"; import * as recast from "recast"; import * as tsParser from "recast/parsers/typescript"; import { builders as b, namedTypes as n } from "ast-types"; import { Context } from "ast-types/lib/path-visitor"; import K from "ast-types/gen/kinds"; import { NodePath } from "ast-types/lib/node-path"; import assert from "assert"; import fs from "fs"; import path from "path"; import process from "process"; const checkExpression = (node: n.Node): node is K.ExpressionKind => n.Expression.check(node); const checkStatement = (node: n.Node): node is K.StatementKind => n.Statement.check(node); for (const file of process.argv.slice(2)) { console.log("Parsing", file); const ast = recast.parse(fs.readFileSync(file, { encoding: "utf8" }), { parser: path.extname(file) === ".ts" ? tsParser : babelParser, }); let changed = false; let inLoop = false; let replaceReturn = false; const visitLoop = (...args: string[]) => function(this: Context, path: NodePath) { for (const arg of args) { this.visit(path.get(arg)); } const old = { inLoop }; inLoop = true; this.visit(path.get("body")); inLoop = old.inLoop; return false; }; recast.visit(ast, { visitDoWhileStatement: visitLoop("test"), visitExpressionStatement(path) { const { expression, comments } = path.node; let valueOnly; if ( n.CallExpression.check(expression) && n.MemberExpression.check(expression.callee) && !expression.callee.computed && n.Identifier.check(expression.callee.object) && expression.callee.object.name === "_" && n.Identifier.check(expression.callee.property) && ["each", "forEach"].includes(expression.callee.property.name) && [2, 3].includes(expression.arguments.length) && checkExpression(expression.arguments[0]) && (n.FunctionExpression.check(expression.arguments[1]) || n.ArrowFunctionExpression.check(expression.arguments[1])) && [1, 2].includes(expression.arguments[1].params.length) && n.Identifier.check(expression.arguments[1].params[0]) && ((valueOnly = expression.arguments[1].params[1] === undefined) || n.Identifier.check(expression.arguments[1].params[1])) && (expression.arguments[2] === undefined || n.ThisExpression.check(expression.arguments[2])) ) { const old = { inLoop, replaceReturn }; inLoop = false; replaceReturn = true; this.visit( path .get("expression") .get("arguments") .get(1) .get("body") ); inLoop = old.inLoop; replaceReturn = old.replaceReturn; const [right, { body, params }] = expression.arguments; const loop = b.forOfStatement( b.variableDeclaration("let", [ b.variableDeclarator( valueOnly ? params[0] : b.arrayPattern([params[1], params[0]]) ), ]), valueOnly ? right : b.callExpression( b.memberExpression(right, b.identifier("entries")), [] ), checkStatement(body) ? body : b.expressionStatement(body) ); loop.comments = comments; path.replace(loop); changed = true; } this.traverse(path); }, visitForStatement: visitLoop("init", "test", "update"), visitForInStatement: visitLoop("left", "right"), visitForOfStatement: visitLoop("left", "right"), visitFunction(path) { this.visit(path.get("params")); const old = { replaceReturn }; replaceReturn = false; this.visit(path.get("body")); replaceReturn = old.replaceReturn; return false; }, visitReturnStatement(path) { if (replaceReturn) { assert(!inLoop); // could use labeled continue if this ever fires const { argument, comments } = path.node; if (argument === null) { const s = b.continueStatement(); s.comments = comments; path.replace(s); } else { const s = b.expressionStatement(argument); s.comments = comments; path.replace(s, b.continueStatement()); } return false; } this.traverse(path); }, visitWhileStatement: visitLoop("test"), }); if (changed) { console.log("Writing", file); fs.writeFileSync(file, recast.print(ast).code, { encoding: "utf8" }); } } Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-02-06 06:19:47 +01:00
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 zfilt table, or because we added
// messages above it and re-collapsed).
if ($elements.length > 0) {
const time = entry.time;
const time_above = entry.time_above;
const rendered_time = render_now(time, today);
const rendered_time_above = time_above ? render_now(time_above, today) : undefined;
for (const element of $elements) {
render_date_span($(element), rendered_time, rendered_time_above);
js: Automatically convert _.each to for…of. This commit was automatically generated by the following script, followed by lint --fix and a few small manual lint-related cleanups. import * as babelParser from "recast/parsers/babel"; import * as recast from "recast"; import * as tsParser from "recast/parsers/typescript"; import { builders as b, namedTypes as n } from "ast-types"; import { Context } from "ast-types/lib/path-visitor"; import K from "ast-types/gen/kinds"; import { NodePath } from "ast-types/lib/node-path"; import assert from "assert"; import fs from "fs"; import path from "path"; import process from "process"; const checkExpression = (node: n.Node): node is K.ExpressionKind => n.Expression.check(node); const checkStatement = (node: n.Node): node is K.StatementKind => n.Statement.check(node); for (const file of process.argv.slice(2)) { console.log("Parsing", file); const ast = recast.parse(fs.readFileSync(file, { encoding: "utf8" }), { parser: path.extname(file) === ".ts" ? tsParser : babelParser, }); let changed = false; let inLoop = false; let replaceReturn = false; const visitLoop = (...args: string[]) => function(this: Context, path: NodePath) { for (const arg of args) { this.visit(path.get(arg)); } const old = { inLoop }; inLoop = true; this.visit(path.get("body")); inLoop = old.inLoop; return false; }; recast.visit(ast, { visitDoWhileStatement: visitLoop("test"), visitExpressionStatement(path) { const { expression, comments } = path.node; let valueOnly; if ( n.CallExpression.check(expression) && n.MemberExpression.check(expression.callee) && !expression.callee.computed && n.Identifier.check(expression.callee.object) && expression.callee.object.name === "_" && n.Identifier.check(expression.callee.property) && ["each", "forEach"].includes(expression.callee.property.name) && [2, 3].includes(expression.arguments.length) && checkExpression(expression.arguments[0]) && (n.FunctionExpression.check(expression.arguments[1]) || n.ArrowFunctionExpression.check(expression.arguments[1])) && [1, 2].includes(expression.arguments[1].params.length) && n.Identifier.check(expression.arguments[1].params[0]) && ((valueOnly = expression.arguments[1].params[1] === undefined) || n.Identifier.check(expression.arguments[1].params[1])) && (expression.arguments[2] === undefined || n.ThisExpression.check(expression.arguments[2])) ) { const old = { inLoop, replaceReturn }; inLoop = false; replaceReturn = true; this.visit( path .get("expression") .get("arguments") .get(1) .get("body") ); inLoop = old.inLoop; replaceReturn = old.replaceReturn; const [right, { body, params }] = expression.arguments; const loop = b.forOfStatement( b.variableDeclaration("let", [ b.variableDeclarator( valueOnly ? params[0] : b.arrayPattern([params[1], params[0]]) ), ]), valueOnly ? right : b.callExpression( b.memberExpression(right, b.identifier("entries")), [] ), checkStatement(body) ? body : b.expressionStatement(body) ); loop.comments = comments; path.replace(loop); changed = true; } this.traverse(path); }, visitForStatement: visitLoop("init", "test", "update"), visitForInStatement: visitLoop("left", "right"), visitForOfStatement: visitLoop("left", "right"), visitFunction(path) { this.visit(path.get("params")); const old = { replaceReturn }; replaceReturn = false; this.visit(path.get("body")); replaceReturn = old.replaceReturn; return false; }, visitReturnStatement(path) { if (replaceReturn) { assert(!inLoop); // could use labeled continue if this ever fires const { argument, comments } = path.node; if (argument === null) { const s = b.continueStatement(); s.comments = comments; path.replace(s); } else { const s = b.expressionStatement(argument); s.comments = comments; path.replace(s, b.continueStatement()); } return false; } this.traverse(path); }, visitWhileStatement: visitLoop("test"), }); if (changed) { console.log("Writing", file); fs.writeFileSync(file, recast.print(ast).code, { encoding: "utf8" }); } } Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-02-06 06:19:47 +01:00
}
maybe_add_update_list_entry({
needs_update: rendered_time.needs_update,
className,
time,
time_above,
});
}
js: Automatically convert _.each to for…of. This commit was automatically generated by the following script, followed by lint --fix and a few small manual lint-related cleanups. import * as babelParser from "recast/parsers/babel"; import * as recast from "recast"; import * as tsParser from "recast/parsers/typescript"; import { builders as b, namedTypes as n } from "ast-types"; import { Context } from "ast-types/lib/path-visitor"; import K from "ast-types/gen/kinds"; import { NodePath } from "ast-types/lib/node-path"; import assert from "assert"; import fs from "fs"; import path from "path"; import process from "process"; const checkExpression = (node: n.Node): node is K.ExpressionKind => n.Expression.check(node); const checkStatement = (node: n.Node): node is K.StatementKind => n.Statement.check(node); for (const file of process.argv.slice(2)) { console.log("Parsing", file); const ast = recast.parse(fs.readFileSync(file, { encoding: "utf8" }), { parser: path.extname(file) === ".ts" ? tsParser : babelParser, }); let changed = false; let inLoop = false; let replaceReturn = false; const visitLoop = (...args: string[]) => function(this: Context, path: NodePath) { for (const arg of args) { this.visit(path.get(arg)); } const old = { inLoop }; inLoop = true; this.visit(path.get("body")); inLoop = old.inLoop; return false; }; recast.visit(ast, { visitDoWhileStatement: visitLoop("test"), visitExpressionStatement(path) { const { expression, comments } = path.node; let valueOnly; if ( n.CallExpression.check(expression) && n.MemberExpression.check(expression.callee) && !expression.callee.computed && n.Identifier.check(expression.callee.object) && expression.callee.object.name === "_" && n.Identifier.check(expression.callee.property) && ["each", "forEach"].includes(expression.callee.property.name) && [2, 3].includes(expression.arguments.length) && checkExpression(expression.arguments[0]) && (n.FunctionExpression.check(expression.arguments[1]) || n.ArrowFunctionExpression.check(expression.arguments[1])) && [1, 2].includes(expression.arguments[1].params.length) && n.Identifier.check(expression.arguments[1].params[0]) && ((valueOnly = expression.arguments[1].params[1] === undefined) || n.Identifier.check(expression.arguments[1].params[1])) && (expression.arguments[2] === undefined || n.ThisExpression.check(expression.arguments[2])) ) { const old = { inLoop, replaceReturn }; inLoop = false; replaceReturn = true; this.visit( path .get("expression") .get("arguments") .get(1) .get("body") ); inLoop = old.inLoop; replaceReturn = old.replaceReturn; const [right, { body, params }] = expression.arguments; const loop = b.forOfStatement( b.variableDeclaration("let", [ b.variableDeclarator( valueOnly ? params[0] : b.arrayPattern([params[1], params[0]]) ), ]), valueOnly ? right : b.callExpression( b.memberExpression(right, b.identifier("entries")), [] ), checkStatement(body) ? body : b.expressionStatement(body) ); loop.comments = comments; path.replace(loop); changed = true; } this.traverse(path); }, visitForStatement: visitLoop("init", "test", "update"), visitForInStatement: visitLoop("left", "right"), visitForOfStatement: visitLoop("left", "right"), visitFunction(path) { this.visit(path.get("params")); const old = { replaceReturn }; replaceReturn = false; this.visit(path.get("body")); replaceReturn = old.replaceReturn; return false; }, visitReturnStatement(path) { if (replaceReturn) { assert(!inLoop); // could use labeled continue if this ever fires const { argument, comments } = path.node; if (argument === null) { const s = b.continueStatement(); s.comments = comments; path.replace(s); } else { const s = b.expressionStatement(argument); s.comments = comments; path.replace(s, b.continueStatement()); } return false; } this.traverse(path); }, visitWhileStatement: visitLoop("test"), }); if (changed) { console.log("Writing", file); fs.writeFileSync(file, recast.print(ast).code, { encoding: "utf8" }); } } Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-02-06 06:19:47 +01:00
}
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);
}
export function get_timestamp_for_flatpickr(timestring: string): Date {
let timestamp;
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.
if (!timestamp || !isValid(timestamp)) {
timestamp = new Date();
}
}
return timestamp;
}
export function stringify_time(time: number | Date): string {
if (user_settings.twenty_four_hour_time) {
return format(time, "HH:mm");
}
return format(time, "h:mm a");
}
export function format_time_modern(time: number | Date, today = new Date()): String {
const hours = differenceInHours(today, time);
const days_old = differenceInCalendarDays(today, time);
if (time > today) {
/* For timestamps in the future, we always show the year*/
return format(time, "MMM\u00A0dd,\u00A0yyyy");
} else if (hours < 24) {
return stringify_time(time);
} else if (days_old === 1) {
return $t({defaultMessage: "Yesterday"});
} else if (days_old < 7) {
return format(time, "EEEE");
} else if (days_old <= 180) {
return format(time, "MMM\u00A0dd");
}
return format(time, "MMM\u00A0dd,\u00A0yyyy");
}
// 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, today = new Date()): string {
const date = new Date(timestamp);
const is_older_year = today.getFullYear() - date.getFullYear() > 0;
const H_24 = user_settings.twenty_four_hour_time;
return format(
date,
is_older_year
? H_24
? "MMM d, yyyy HH:mm"
: "MMM d, yyyy hh:mm a"
: H_24
? "MMM d HH:mm"
: "MMM d hh:mm a",
);
}
export function get_full_datetime(time: Date): string {
const time_options: Intl.DateTimeFormatOptions = {timeStyle: "medium"};
if (user_settings.twenty_four_hour_time) {
time_options.hourCycle = "h24";
}
const date_string = time.toLocaleDateString();
let time_string = time.toLocaleTimeString(undefined, time_options);
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});
}