thelounge/client/js/lounge.js

689 lines
17 KiB
JavaScript
Raw Normal View History

"use strict";
2016-12-18 15:53:28 +00:00
// vendor libraries
require("jquery-ui/ui/widgets/sortable");
const $ = require("jquery");
const moment = require("moment");
const URI = require("urijs");
const fuzzy = require("fuzzy");
2016-12-18 15:53:28 +00:00
// our libraries
require("./libs/jquery/inputhistory");
require("./libs/jquery/stickyscroll");
const slideoutMenu = require("./libs/slideout");
const templates = require("../views");
2017-04-18 07:31:46 +00:00
const socket = require("./socket");
const render = require("./render");
2017-05-18 20:08:54 +00:00
require("./socket-events");
2017-04-22 13:03:00 +00:00
const storage = require("./localStorage");
2017-05-18 20:08:54 +00:00
const utils = require("./utils");
2017-07-10 19:47:03 +00:00
require("./webpush");
2017-09-06 06:41:11 +00:00
require("./keybinds");
2017-08-26 16:36:18 +00:00
require("./clipboard");
2014-08-16 16:15:17 +00:00
$(function() {
2014-09-19 23:12:17 +00:00
var sidebar = $("#sidebar, #footer");
2014-08-16 16:15:17 +00:00
var chat = $("#chat");
2014-09-10 19:23:56 +00:00
$(document.body).data("app-name", document.title);
2014-08-16 16:15:17 +00:00
var viewport = $("#viewport");
2016-12-18 15:53:28 +00:00
var sidebarSlide = slideoutMenu(viewport[0], sidebar[0]);
2016-02-12 11:34:10 +00:00
var contextMenuContainer = $("#context-menu-container");
var contextMenu = $("#context-menu");
2014-08-16 16:15:17 +00:00
2016-06-12 02:16:17 +00:00
$("#main").on("click", function(e) {
if ($(e.target).is(".lt")) {
sidebarSlide.toggle(!sidebarSlide.isOpen());
} else if (sidebarSlide.isOpen()) {
sidebarSlide.toggle(false);
}
});
viewport.on("click", ".rt", function(e) {
2014-08-16 16:15:17 +00:00
var self = $(this);
viewport.toggleClass(self.attr("class"));
2016-06-12 02:16:17 +00:00
e.stopPropagation();
chat.find(".chan.active .chat").trigger("msg.sticky");
2014-08-16 16:15:17 +00:00
});
2016-03-19 18:20:11 +00:00
function positionContextMenu(that, e) {
var offset;
2016-02-12 11:34:10 +00:00
var menuWidth = contextMenu.outerWidth();
var menuHeight = contextMenu.outerHeight();
2016-03-19 18:20:11 +00:00
if (that.hasClass("menu")) {
offset = that.offset();
offset.left -= menuWidth - that.outerWidth();
offset.top += that.outerHeight();
return offset;
2016-02-12 11:34:10 +00:00
}
2016-03-19 18:20:11 +00:00
offset = {left: e.pageX, top: e.pageY};
if ((window.innerWidth - offset.left) < menuWidth) {
offset.left = window.innerWidth - menuWidth;
2016-02-12 11:34:10 +00:00
}
2016-03-19 18:20:11 +00:00
if ((window.innerHeight - offset.top) < menuHeight) {
offset.top = window.innerHeight - menuHeight;
}
return offset;
2016-02-12 11:34:10 +00:00
}
2016-03-19 18:20:11 +00:00
function showContextMenu(that, e) {
2016-02-12 11:34:10 +00:00
var target = $(e.currentTarget);
var output = "";
if (target.hasClass("user")) {
2016-12-18 15:53:28 +00:00
output = templates.contextmenu_item({
2016-02-12 11:34:10 +00:00
class: "user",
action: "whois",
2016-02-12 11:34:10 +00:00
text: target.text(),
data: target.data("name"),
2016-02-12 11:34:10 +00:00
});
output += templates.contextmenu_divider();
output += templates.contextmenu_item({
class: "action-whois",
action: "whois",
text: "User information",
data: target.data("name"),
});
output += templates.contextmenu_item({
class: "action-query",
action: "query",
text: "Direct messages",
data: target.data("name"),
});
const channel = target.closest(".chan");
if (utils.isOpInChannel(channel) && channel.data("type") === "channel") {
output += templates.contextmenu_divider();
output += templates.contextmenu_item({
class: "action-kick",
action: "kick",
text: "Kick",
data: target.data("name"),
});
}
} else if (target.hasClass("chan")) {
let itemClass;
if (target.hasClass("lobby")) {
itemClass = "network";
} else if (target.hasClass("query")) {
itemClass = "query";
} else {
itemClass = "chan";
}
2016-12-18 15:53:28 +00:00
output = templates.contextmenu_item({
class: itemClass,
action: "focusChan",
2016-02-12 11:34:10 +00:00
text: target.data("title"),
data: target.data("target"),
2016-02-12 11:34:10 +00:00
});
2016-12-18 15:53:28 +00:00
output += templates.contextmenu_divider();
if (target.hasClass("lobby")) {
output += templates.contextmenu_item({
class: "list",
action: "list",
text: "List all channels",
data: target.data("id"),
});
output += templates.contextmenu_item({
class: "join",
action: "join",
text: "Join a channel\u2026",
data: target.data("id"),
});
}
2017-12-19 11:39:50 +00:00
if (target.hasClass("channel")) {
output += templates.contextmenu_item({
class: "list",
action: "banlist",
text: "List banned users",
data: target.data("id"),
});
}
2016-12-18 15:53:28 +00:00
output += templates.contextmenu_item({
2016-02-12 11:34:10 +00:00
class: "close",
action: "close",
text: target.hasClass("lobby") ? "Disconnect" : target.hasClass("channel") ? "Leave" : "Close",
data: target.data("target"),
2016-02-12 11:34:10 +00:00
});
}
contextMenuContainer.show();
contextMenu
.html(output)
2016-03-19 18:20:11 +00:00
.css(positionContextMenu($(that), e));
2016-02-12 11:34:10 +00:00
return false;
2016-03-19 18:20:11 +00:00
}
viewport.on("contextmenu", ".network .chan", function(e) {
return showContextMenu(this, e);
});
viewport.on("click contextmenu", ".user", function(e) {
2016-03-19 18:20:11 +00:00
return showContextMenu(this, e);
});
viewport.on("click", "#chat .menu", function(e) {
e.currentTarget = $(e.currentTarget).closest(".chan")[0];
return showContextMenu(this, e);
2016-02-12 11:34:10 +00:00
});
contextMenuContainer.on("click contextmenu", function() {
contextMenuContainer.hide();
return false;
});
function resetInputHeight(input) {
input.style.height = input.style.minHeight;
}
2014-08-16 16:15:17 +00:00
var input = $("#input")
.history()
2017-04-03 01:03:01 +00:00
.on("input", function() {
var style = window.getComputedStyle(this);
2016-07-15 06:42:47 +00:00
// Start by resetting height before computing as scrollHeight does not
// decrease when deleting characters
resetInputHeight(this);
2016-07-15 06:42:47 +00:00
this.style.height = Math.min(
Math.round(window.innerHeight - 100), // prevent overflow
this.scrollHeight
+ Math.round(parseFloat(style.borderTopWidth) || 0)
+ Math.round(parseFloat(style.borderBottomWidth) || 0)
) + "px";
chat.find(".chan.active .chat").trigger("msg.sticky"); // fix growing
});
2014-09-10 19:23:56 +00:00
2016-12-16 19:55:02 +00:00
var focus = $.noop;
if (!("ontouchstart" in window || navigator.maxTouchPoints > 0)) {
focus = function() {
if (chat.find(".active").hasClass("chan")) {
input.focus();
}
};
$(window).on("focus", focus);
chat.on("click", ".chat", function() {
setTimeout(function() {
var text = "";
if (window.getSelection) {
text = window.getSelection().toString();
} else if (document.selection && document.selection.type !== "Control") {
text = document.selection.createRange().text;
}
if (!text) {
focus();
}
}, 2);
});
}
2017-09-19 09:18:54 +00:00
if (navigator.platform.match(/(Mac|iPhone|iPod|iPad)/i)) {
$(document.body).addClass("is-apple");
}
2016-10-09 08:54:44 +00:00
$("#form").on("submit", function(e) {
2014-08-16 16:15:17 +00:00
e.preventDefault();
2017-05-18 20:08:54 +00:00
utils.forceFocus();
2014-08-16 16:15:17 +00:00
var text = input.val();
2016-05-25 07:23:03 +00:00
if (text.length === 0) {
return;
}
2014-08-16 16:15:17 +00:00
input.val("");
resetInputHeight(input.get(0));
2016-05-25 07:23:03 +00:00
2017-10-05 17:12:26 +00:00
if (text.charAt(0) === "/") {
const args = text.substr(1).split(" ");
const cmd = args.shift().toLowerCase();
if (typeof utils.inputCommands[cmd] === "function" && utils.inputCommands[cmd](args)) {
return;
}
}
2014-08-16 16:15:17 +00:00
socket.emit("input", {
target: chat.data("id"),
text: text,
2014-08-16 16:15:17 +00:00
});
});
$("button#set-nick").on("click", function() {
2017-05-18 20:08:54 +00:00
utils.toggleNickEditor(true);
// Selects existing nick in the editable text field
var element = document.querySelector("#nick-value");
element.focus();
var range = document.createRange();
range.selectNodeContents(element);
var selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
});
$("button#cancel-nick").on("click", cancelNick);
$("button#submit-nick").on("click", submitNick);
function submitNick() {
var newNick = $("#nick-value").text().trim();
if (newNick.length === 0) {
cancelNick();
return;
}
2017-05-18 20:08:54 +00:00
utils.toggleNickEditor(false);
socket.emit("input", {
target: chat.data("id"),
text: "/nick " + newNick,
});
}
function cancelNick() {
2017-05-18 20:08:54 +00:00
utils.setNick(sidebar.find(".chan.active").closest(".network").data("nick"));
}
$("#nick-value").keypress(function(e) {
switch (e.keyCode ? e.keyCode : e.which) {
case 13: // Enter
// Ensures a new line is not added when pressing Enter
e.preventDefault();
break;
}
}).keyup(function(e) {
switch (e.keyCode ? e.keyCode : e.which) {
case 13: // Enter
submitNick();
break;
case 27: // Escape
cancelNick();
break;
}
});
chat.on("click", ".inline-channel", function() {
var name = $(this).data("chan");
var chan = utils.findCurrentNetworkChan(name);
if (chan.length) {
chan.click();
} else {
socket.emit("input", {
target: chat.data("id"),
text: "/join " + name,
});
}
});
chat.on("click", ".condensed-summary .content", function() {
$(this).closest(".msg.condensed").toggleClass("closed");
2017-06-22 20:08:36 +00:00
});
sidebar.on("click", ".chan, button", function(e, data) {
// Pushes states to history web API when clicking elements with a data-target attribute.
// States are very trivial and only contain a single `clickTarget` property which
// contains a CSS selector that targets elements which takes the user to a different view
// when clicked. The `popstate` event listener will trigger synthetic click events using that
// selector and thus take the user to a different view/state.
if (data && data.pushState === false) {
return;
}
const self = $(this);
const target = self.data("target");
if (!target) {
return;
}
const state = {};
if (self.hasClass("chan")) {
state.clickTarget = `#sidebar .chan[data-id="${self.data("id")}"]`;
} else {
state.clickTarget = `#footer button[data-target="${target}"]`;
}
if (history && history.pushState) {
if (data && data.replaceHistory && history.replaceState) {
history.replaceState(state, null, target);
} else {
history.pushState(state, null, target);
}
}
});
sidebar.on("click", ".chan, button", function() {
2014-08-16 16:15:17 +00:00
var self = $(this);
var target = self.data("target");
if (!target) {
return;
}
chat.data(
"id",
self.data("id")
);
socket.emit(
"open",
self.data("id")
);
2014-08-16 16:15:17 +00:00
sidebar.find(".active").removeClass("active");
self.addClass("active")
.find(".badge")
.removeClass("highlight")
.empty();
2014-09-13 17:36:59 +00:00
if (sidebar.find(".highlight").length === 0) {
2017-05-18 20:08:54 +00:00
utils.toggleNotificationMarkers(false);
2014-08-16 16:15:17 +00:00
}
2016-06-12 02:16:17 +00:00
sidebarSlide.toggle(false);
var lastActive = $("#windows > .active");
2016-05-13 10:23:05 +00:00
lastActive
2016-04-17 10:50:03 +00:00
.removeClass("active")
.find(".chat")
.unsticky();
const lastActiveChan = lastActive.find(".chan.active");
2016-07-05 23:23:46 +00:00
if (lastActiveChan.length > 0) {
lastActiveChan
.removeClass("active")
.find(".unread-marker")
.data("unread-id", 0)
.appendTo(lastActiveChan.find(".messages"));
render.trimMessageInChannel(lastActiveChan, 100);
}
2014-08-16 16:15:17 +00:00
var chan = $(target)
.addClass("active")
2016-07-05 23:23:46 +00:00
.trigger("show");
2016-04-17 10:50:03 +00:00
let title = $(document.body).data("app-name");
2014-11-07 19:52:38 +00:00
if (chan.data("title")) {
title = chan.data("title") + " — " + title;
}
document.title = title;
2014-12-11 22:42:22 +00:00
2017-08-15 09:44:29 +00:00
const type = chan.data("type");
var placeholder = "";
2017-08-15 09:44:29 +00:00
if (type === "channel" || type === "query") {
placeholder = `Write to ${chan.data("title")}`;
}
input.attr("placeholder", placeholder);
2014-09-25 23:51:53 +00:00
if (self.hasClass("chan")) {
$("#chat-container").addClass("active");
2017-05-18 20:08:54 +00:00
utils.setNick(self.closest(".network").data("nick"));
2014-09-25 23:51:53 +00:00
}
var chanChat = chan.find(".chat");
if (chanChat.length > 0 && chan.data("type") !== "special") {
chanChat.sticky();
}
if (chan.data("needsNamesRefresh") === true) {
chan.data("needsNamesRefresh", false);
socket.emit("names", {target: self.data("id")});
}
2017-08-15 09:44:29 +00:00
if (type === "settings") {
$("#session-list").html("<p>Loading…</p>");
socket.emit("sessions:get");
}
2016-12-16 19:55:02 +00:00
focus();
2014-08-16 16:15:17 +00:00
});
sidebar.on("click", "#sign-out", function() {
2017-08-13 18:37:12 +00:00
socket.emit("sign-out");
2017-04-22 13:03:00 +00:00
storage.remove("token");
if (!socket.connected) {
location.reload();
}
2014-08-16 16:15:17 +00:00
});
function closeChan(chan) {
2014-08-16 16:15:17 +00:00
var cmd = "/close";
2014-08-16 16:15:17 +00:00
if (chan.hasClass("lobby")) {
cmd = "/quit";
var server = chan.find(".name").html();
if (!confirm("Disconnect from " + server + "?")) { // eslint-disable-line no-alert
2014-08-16 16:15:17 +00:00
return false;
}
}
socket.emit("input", {
target: chan.data("id"),
text: cmd,
2014-08-16 16:15:17 +00:00
});
chan.css({
transition: "none",
opacity: 0.4,
2014-08-16 16:15:17 +00:00
});
return false;
}
sidebar.on("click", ".close", function() {
closeChan($(this).closest(".chan"));
2014-08-16 16:15:17 +00:00
});
sidebar.on("click", ".add-channel", (e) => {
const id = $(e.target).data("id");
const joinForm = $(`#join-channel-${id}`);
joinForm.toggle();
joinForm.find(".input[name='channel']").focus();
return false;
});
sidebar.on("submit", ".join-form", function() {
const form = $(this);
const channel = form.find("input[name='channel']");
const channelString = channel.val();
const key = form.find("input[name='key']");
const keyString = key.val();
const chan = utils.findCurrentNetworkChan(channelString);
if (chan.length) {
chan.click();
} else {
socket.emit("input", {
text: `/join ${channelString} ${keyString}`,
target: form.prev().data("id"),
});
}
channel.val("");
key.val("");
form.hide();
return false;
});
const contextMenuActions = {
join: function(itemData) {
$(`#join-channel-${itemData}`).show();
},
close: function(itemData) {
closeChan($(`.networks .chan[data-target="${itemData}"]`));
},
focusChan: function(itemData) {
$(`.networks .chan[data-target="${itemData}"]`).click();
},
list: function(itemData) {
socket.emit("input", {
target: itemData,
text: "/list",
});
},
2017-12-19 11:39:50 +00:00
banlist: function(itemData) {
socket.emit("input", {
target: itemData,
text: "/banlist",
});
},
whois: function(itemData) {
const chan = utils.findCurrentNetworkChan(itemData);
if (chan.length) {
chan.click();
}
socket.emit("input", {
target: $("#chat").data("id"),
text: "/whois " + itemData,
});
$(`.channel.active .users .user[data-name="${itemData}"]`).click();
},
query: function(itemData) {
const chan = utils.findCurrentNetworkChan(itemData);
if (chan.length) {
chan.click();
}
socket.emit("input", {
target: $("#chat").data("id"),
text: "/query " + itemData,
});
},
kick: function(itemData) {
socket.emit("input", {
target: $("#chat").data("id"),
text: "/kick " + itemData,
});
},
};
contextMenuActions.execute = (name, ...args) => contextMenuActions[name] && contextMenuActions[name](...args);
contextMenu.on("click", ".context-menu-item", function() {
const $this = $(this);
const itemData = $this.data("data");
const contextAction = $this.data("action");
contextMenuActions.execute(contextAction, itemData);
2016-02-12 11:34:10 +00:00
});
2014-08-16 16:15:17 +00:00
chat.on("input", ".search", function() {
const value = $(this).val();
const parent = $(this).closest(".users");
const names = parent.find(".names-original");
const container = parent.find(".names-filtered");
if (!value.length) {
container.hide();
names.show();
return;
}
const fuzzyOptions = {
pre: "<b>",
post: "</b>",
extract: (el) => $(el).text(),
};
const result = fuzzy.filter(
value,
names.find(".user").toArray(),
fuzzyOptions
);
names.hide();
container.html(templates.user_filtered({matches: result})).show();
2014-08-16 16:15:17 +00:00
});
if ($("body").hasClass("public") && (window.location.hash === "#connect" || window.location.hash === "")) {
$("#connect").one("show", function() {
2016-12-18 15:53:28 +00:00
var params = URI(document.location.search);
params = params.search(true);
// Possible parameters: name, host, port, password, tls, nick, username, realname, join
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for...in#Iterating_over_own_properties_only
for (var key in params) {
if (params.hasOwnProperty(key)) {
var value = params[key];
// \W searches for non-word characters
key = key.replace(/\W/g, "");
var element = $("#connect input[name='" + key + "']");
// if the element exists, it isn't disabled, and it isn't hidden
if (element.length > 0 && !element.is(":disabled") && !element.is(":hidden")) {
if (element.is(":checkbox")) {
element.prop("checked", (value === "1" || value === "true") ? true : false);
} else {
element.val(value);
}
}
}
}
});
}
2017-06-09 11:10:55 +00:00
$(document).on("visibilitychange focus click", () => {
if (sidebar.find(".highlight").length === 0) {
2017-05-18 20:08:54 +00:00
utils.toggleNotificationMarkers(false);
2014-08-16 16:15:17 +00:00
}
});
// Compute how many milliseconds are remaining until the next day starts
function msUntilNextDay() {
return moment().add(1, "day").startOf("day") - moment();
}
// Go through all Today/Yesterday date markers in the DOM and recompute their
// labels. When done, restart the timer for the next day.
function updateDateMarkers() {
$(".date-marker-text[data-label='Today'], .date-marker-text[data-label='Yesterday']")
.closest(".date-marker-container")
.each(function() {
2017-09-01 11:43:24 +00:00
$(this).replaceWith(templates.date_marker({time: $(this).data("time")}));
});
// This should always be 24h later but re-computing exact value just in case
setTimeout(updateDateMarkers, msUntilNextDay());
}
setTimeout(updateDateMarkers, msUntilNextDay());
window.addEventListener("popstate", (e) => {
const {state} = e;
if (!state) {
return;
}
2017-09-09 20:36:06 +00:00
let {clickTarget} = state;
if (clickTarget) {
2017-09-09 20:36:06 +00:00
// This will be true when click target corresponds to opening a thumbnail,
// browsing to the previous/next thumbnail, or closing the image viewer.
const imageViewerRelated = clickTarget.includes(".toggle-thumbnail");
2017-09-09 20:36:06 +00:00
// If the click target is not related to the image viewer but the viewer
// is currently opened, we need to close it.
if (!imageViewerRelated && $("#image-viewer").hasClass("opened")) {
clickTarget += ", #image-viewer";
}
// Emit the click to the target, while making sure it is not going to be
// added to the state again.
$(clickTarget).trigger("click", {
pushState: false,
});
}
});
// Only start opening socket.io connection after all events have been registered
socket.open();
2014-08-16 16:15:17 +00:00
});