Bring missing features to Google Chat.
// ==UserScript==
// @name Enhanced Google Chat
// @namespace http://tampermonkey.net/
// @version 2023-12-30
// @description Bring missing features to Google Chat.
// @author @higuoxing
// @license MIT
// @match https://mail.google.com/chat/u/*
// @match https://chat.google.com/u/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=chat.google.com
// @require https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js
// @resource REMOTE_CSS https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/default.min.css
// @grant GM_getResourceText
// @grant GM_addStyle
// ==/UserScript==
/* global hljs */
const config = {
// Valid options are:
// "None": Don't modify the default key bindings of Google Chat.
// "Ctrl-Enter": Use "Ctrl-Enter" to send messages and "Enter" to insert a new line.
// "Enter": Use "Enter" to send messages and "Ctrl-Enter" to insert a new line.
send_message: "Ctrl-Enter",
};
function render_code_blocks() {
let spans = document.getElementsByTagName("span");
for (let span of spans) {
let data_cd_attr = span.getAttribute("data-cd");
// We use <span data-cd="hidden">```</span> to identify code blocks.
if (data_cd_attr === "hidden" && span.textContent === "```") {
let next_sibling_element = span.nextElementSibling;
if (next_sibling_element != null && next_sibling_element.getAttribute("role") === "complementary") {
// The next sibling element is the content
// of the code block.
let parent_div = span.parentElement;
let orig_code_block = span.nextElementSibling;
// Determine the language.
let orig_code_content = orig_code_block.innerText;
let orig_code_lines = orig_code_content.split('\n');
if (orig_code_lines.length < 1) {
continue;
}
let language = orig_code_lines[0];
// Check if hljs can highlight our language.
if (hljs.getLanguage(language) === undefined || hljs.getLanguage(language) === null) {
continue;
}
// Create code container.
let pre_ele = document.createElement("pre");
let code_ele = document.createElement("code");
code_ele.setAttribute("class", "language-" + language);
// Remove the 1st line that specifies the language.
orig_code_lines.shift();
// Append our new code block.
code_ele.textContent = orig_code_lines.join('\n');
pre_ele.appendChild(code_ele);
// We're ready to highlight it.
hljs.highlightElement(code_ele);
let language_mark = document.createElement("span");
language_mark.setAttribute("class", "hljs-language-mark");
language_mark.textContent = language + ":";
// Create a new code container.
let code_container = document.createElement("div");
code_container.appendChild(language_mark);
code_container.appendChild(pre_ele);
// Append it to the parent element.
parent_div.insertBefore(code_container, orig_code_block);
// Remove the original code block.
orig_code_block.remove();
}
// Remove the <span> tag so that we won't render the code block twice.
span.remove();
}
}
}
function register_enter_key_handler(element, config) {
element.setAttribute('enter-key-event-registered', 'true');
element.addEventListener('keydown', (e) => {
// Only let it go if the ctrl key is down.
// Just don't call preventDefault(), a new line will be created always which is the
// textfield's default behaviour.
if (e.key == 'Enter' && ((config.send_message == "Ctrl-Enter" && !e.ctrlKey) || (config.send_message == "Enter" && e.ctrlKey))) {
// Get the pop up list after inputting "@" (for tagging people) or ':' (for inserting emojis), etc.
let div_nodes = document.getElementsByTagName('div');
let list_expanded = false;
for (let div of div_nodes) {
if (div.getAttribute('role') === 'listbox' && div.getAttribute('data-expanded') === 'true') {
// Do not intercept enter key if the pop up list is visible.
list_expanded = true;
}
}
if (!list_expanded) {
e.stopImmediatePropagation();
}
}
}, true);
}
function check_user_config() {
if (config.send_message != "None" && config.send_message != "Ctrl-Enter" && config.send_message != "Enter") {
alert("Invalid value for config.send_message. Please check enhanced-google-chat.user.js.");
}
}
// Called only once.
function initialize() {
// Initialize stylesheets.
const hljs_css = GM_getResourceText("REMOTE_CSS");
GM_addStyle(hljs_css);
// I'm not a CSS expert, we force the font family of every hljs elements to be monospace.
GM_addStyle(`[class^="hljs-"], [class*=" hljs-"], [class^="hljs"], [class*=" hljs"] {
font-family: "Roboto Mono",monospace;
}
.hljs-language-mark {
font-size: 0.8em;
}
.hljs {
background: #fafafa;
}
`
);
check_user_config(config);
}
function modify_key_event() {
if (config.send_message != "None") {
// register event on the chat text input box
let div_nodes = document.getElementsByTagName('div');
for (let div of div_nodes) {
// The id of the input text area varies, we use 'role', 'aria-label' and 'contenteditable' attributes
// to locate the element.
if (div.getAttribute('role') === 'textbox' &&
div.getAttribute('contenteditable') === 'true' &&
div.getAttribute('enter-key-event-registered') != 'true') {
register_enter_key_handler(div, config);
}
}
}
}
// Called periodically.
function main() {
render_code_blocks();
modify_key_event();
}
function debounce(fn, delay) {
let timeout = null;
return function () {
if (timeout) {
return;
} else {
timeout = setTimeout(function () {
fn();
timeout = null;
}, delay);
}
}
}
(function () {
'use strict';
if (window.trustedTypes && window.trustedTypes.createPolicy) {
window.trustedTypes.createPolicy('default', {
createHTML: (string, sink) => string
});
}
initialize();
let el = document.documentElement;
el.addEventListener('DOMSubtreeModified', debounce(main, 1000));
})();