Files
zulip/static/js/rendered_markdown.js
Nikhil Maske f5544e36ed markdown: Add fa-clock icon before markdown timestamps.
These sigils will help make it easier to see that this is a special
Zulip syntax feature, not just something the other user typed, which
might help set the expectation that we're showing the time in the
user's timezone.

Tweaked by tabbott to improve variable/template naming.
2021-03-14 22:57:01 -07:00

240 lines
9.2 KiB
JavaScript

import ClipboardJS from "clipboard";
import {parseISO, isValid} from "date-fns";
import $ from "jquery";
import copy_code_button from "../templates/copy_code_button.hbs";
import render_markdown_timestamp from "../templates/markdown_timestamp.hbs";
import view_code_in_playground from "../templates/view_code_in_playground.hbs";
import * as people from "./people";
import * as rtl from "./rtl";
import * as settings_config from "./settings_config";
import * as stream_data from "./stream_data";
import * as timerender from "./timerender";
import * as user_groups from "./user_groups";
/*
rendered_markdown
This module provides a single function 'update_elements' to
update any renamed users/streams/groups etc. and other
dynamic parts of our rendered messages.
Use this module wherever some Markdown rendered content
is being displayed.
*/
function get_user_id_for_mention_button(elem) {
const user_id_string = $(elem).attr("data-user-id");
// Handle legacy Markdown that was rendered before we cut
// over to using data-user-id.
const email = $(elem).attr("data-user-email");
if (user_id_string === "*" || email === "*") {
return "*";
}
if (user_id_string) {
return Number.parseInt(user_id_string, 10);
}
if (email) {
// Will return undefined if there's no match
const user = people.get_by_email(email);
if (user) {
return user.user_id;
}
}
return undefined;
}
function get_user_group_id_for_mention_button(elem) {
const user_group_id = $(elem).attr("data-user-group-id");
if (user_group_id) {
return Number.parseInt(user_group_id, 10);
}
return undefined;
}
// Helper function to update a mentioned user's name.
export function set_name_in_mention_element(element, name) {
if ($(element).hasClass("silent")) {
$(element).text(name);
} else {
$(element).text("@" + name);
}
}
export const update_elements = (content) => {
// Set the rtl class if the text has an rtl direction
if (rtl.get_direction(content.text()) === "rtl") {
content.addClass("rtl");
}
content.find(".user-mention").each(function () {
const user_id = get_user_id_for_mention_button(this);
// We give special highlights to the mention buttons
// that refer to the current user.
if (user_id === "*" || people.is_my_user_id(user_id)) {
// Either a wildcard mention or us, so mark it.
$(this).addClass("user-mention-me");
}
if (user_id && user_id !== "*" && !$(this).find(".highlight").length) {
// If it's a mention of a specific user, edit the
// mention text to show the user's current name,
// assuming that you're not searching for text
// inside the highlight.
const person = people.get_by_user_id(user_id, true);
if (person !== undefined) {
// Note that person might be undefined in some
// unpleasant corner cases involving data import.
set_name_in_mention_element(this, person.full_name);
}
}
});
content.find(".user-group-mention").each(function () {
const user_group_id = get_user_group_id_for_mention_button(this);
const user_group = user_groups.get_user_group_from_id(user_group_id, true);
if (user_group === undefined) {
// This is a user group the current user doesn't have
// data on. This can happen when user groups are
// deleted.
blueslip.info("Rendered unexpected user group " + user_group_id);
return;
}
const my_user_id = people.my_current_user_id();
// Mark user group you're a member of.
if (user_groups.is_member_of(user_group_id, my_user_id)) {
$(this).addClass("user-mention-me");
}
if (user_group_id && !$(this).find(".highlight").length) {
// Edit the mention to show the current name for the
// user group, if its not in search.
$(this).text("@" + user_group.name);
}
});
content.find("a.stream").each(function () {
const stream_id = Number.parseInt($(this).attr("data-stream-id"), 10);
if (stream_id && !$(this).find(".highlight").length) {
// Display the current name for stream if it is not
// being displayed in search highlight.
const stream_name = stream_data.maybe_get_stream_name(stream_id);
if (stream_name !== undefined) {
// If the stream has been deleted,
// stream_data.maybe_get_stream_name might return
// undefined. Otherwise, display the current stream name.
$(this).text("#" + stream_name);
}
}
});
content.find("a.stream-topic").each(function () {
const stream_id = Number.parseInt($(this).attr("data-stream-id"), 10);
if (stream_id && !$(this).find(".highlight").length) {
// Display the current name for stream if it is not
// being displayed in search highlight.
const text = $(this).text();
const topic = text.split(">", 2)[1];
const stream_name = stream_data.maybe_get_stream_name(stream_id);
if (stream_name !== undefined) {
// If the stream has been deleted,
// stream_data.maybe_get_stream_name might return
// undefined. Otherwise, display the current stream name.
$(this).text("#" + stream_name + " > " + topic);
}
}
});
content.find("time").each(function () {
// Populate each timestamp span with mentioned time
// in user's local timezone.
const time_str = $(this).attr("datetime");
if (time_str === undefined) {
return;
}
const timestamp = parseISO(time_str);
if (isValid(timestamp)) {
const text = $(this).text();
const rendered_time = timerender.render_markdown_timestamp(timestamp, text);
const rendered_timestamp = render_markdown_timestamp({
text: rendered_time.text,
});
$(this).html(rendered_timestamp);
$(this).attr("title", rendered_time.title);
} else {
// This shouldn't happen. If it does, we're very interested in debugging it.
blueslip.error(`Could not parse datetime supplied by backend: ${time_str}`);
}
});
content.find("span.timestamp-error").each(function () {
const time_str = $(this).text().replace("Invalid time format: ", "");
const text = i18n.t("Invalid time format: __timestamp__", {timestamp: time_str});
$(this).text(text);
});
content.find("div.spoiler-header").each(function () {
// If a spoiler block has no header content, it should have a default header.
// We do this client side to allow for i18n by the client.
if ($(this).html().trim().length === 0) {
$(this).append(`<p>${i18n.t("Spoiler")}</p>`);
}
// Add the expand/collapse button to spoiler blocks
const toggle_button_html =
'<span class="spoiler-button" aria-expanded="false"><span class="spoiler-arrow"></span></span>';
$(this).prepend(toggle_button_html);
});
// Display the view-code-in-playground and the copy-to-clipboard button inside the div.codehilite element.
content.find("div.codehilite").each(function () {
const $codehilite = $(this);
const $pre = $codehilite.find("pre");
const fenced_code_lang = $codehilite.data("code-language");
if (fenced_code_lang !== undefined) {
const playground_info = settings_config.get_playground_info_for_languages(
fenced_code_lang,
);
if (playground_info !== undefined) {
// If a playground is configured for this language,
// offer to view the code in that playground. When
// there are multiple playgrounds, we display a
// popover listing the options.
let title = i18n.t("View in playground");
const view_in_playground_button = $(view_code_in_playground());
$pre.prepend(view_in_playground_button);
if (playground_info.length === 1) {
title = i18n.t(`View in ${playground_info[0].name}`);
} else {
view_in_playground_button.attr("aria-haspopup", "true");
}
view_in_playground_button.attr("title", title);
view_in_playground_button.attr("aria-label", title);
}
}
const copy_button = $(copy_code_button());
$pre.prepend(copy_button);
new ClipboardJS(copy_button[0], {
text(copy_element) {
return $(copy_element).siblings("code").text();
},
});
});
// Display emoji (including realm emoji) as text if
// page_params.emojiset is 'text'.
if (page_params.emojiset === "text") {
content.find(".emoji").replaceWith(function () {
const text = $(this).attr("title");
return ":" + text + ":";
});
}
};