1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321 |
- /*
- * Sapphire
- *
- * Copyright (C) 2018 Florrie Haero
- * Copyright (C) 2018 Alyssa Rosenzweig
- * Copyright (C) 2018 eq
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program; if not, write to the Free Software
- * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02111-1301 USA
- *
- */
- 'use strict';
- let otrKey = null;
- if (!localStorage.otrKey) {
- otrKey = new window.DSA();
- localStorage.otrKey = otrKey.packPrivate();
- } else {
- otrKey = window.DSA.parsePrivate(localStorage.otrKey);
- }
- const otrTag = window.OTR.makeInstanceTag();
- const otrOptions = {
- fragment_size: 800,
- send_interval: 200,
- priv: otrKey,
- instance_tag: otrTag
- };
- const {
- accountDropdown: tmplAccountDropdown,
- accountDropdownOption: tmplAccountDropdownOption,
- buddyRoomHeader: tmplBuddyRoomHeader,
- chat: tmplChat,
- chatRoomHeader: tmplChatRoomHeader,
- icon: tmplIcon,
- message: tmplMessage,
- messageGroup: tmplMessageGroup,
- sidebarGroup: tmplSidebarGroup,
- user: tmplUser
- } = window.templates;
- const addBuddyButton = document.getElementById('add-buddy');
- const app = document.getElementById('app');
- const buddyList = document.getElementById('buddy-list');
- const changeAvatarButton = document.getElementById('change-avatar');
- const chatForm = document.getElementById('chat-form');
- const chatList = document.getElementById('chat-list');
- const chatTextInput = document.querySelector('[data-id="input"]');
- let chatUserList = document.getElementById('chat-user-list');
- const loginForm = document.getElementById('login-form');
- const loginPasswordInput = document.getElementById('password');
- const loginUsernameInput = document.getElementById('username');
- const loginScreen = document.getElementById('login-screen');
- const loginStatus = document.getElementById('login-status');
- let messageList = document.getElementById('message-list');
- const messageListContainer = document.getElementById('message-list-container');
- const modalScreen = document.getElementById('modal-screen');
- const modalContent = document.getElementById('modal-content');
- const roomBackButton = document.getElementById('room-back');
- let roomInfoContent = document.getElementById('room-info-content');
- const toggleChatUserListButton = document.getElementById('toggle-chat-user-list');
- const typingIndicatorName = document.getElementById('typing-indicator-name');
- const typingIndicatorStatus = document.getElementById('typing-indicator-status');
- const PROTOCOL = window.location.protocol === 'file:' ? 'http:' : window.location.protocol;
- const BASE_URL = window.BASE_HOST + ':7070';
- window.BASE_ICON = PROTOCOL + '//' + BASE_URL + '/icon/?name=';
- // Maximum width at which the mobile layout (buddy list in a separate view
- // from the main chat area) is displayed. MUST be equal to the value specified
- // in the @media in style.css.
- const MOBILE_WIDTH = 600;
- // Values from the API; DO NOT CHANGE without updating the server.
- const PURPLE_MESSAGE_SEND = 1;
- const TYPING_NONE = 0; // Must ALWAYS be falsey
- const TYPING_TYPING = 1;
- const TYPING_STOPPED = 2;
- const userDetailsDict = {};
- const chatDetailsDict = {};
- const roomDetailsDict = {};
- /* Shadow user object representing us, segregated by account to support
- * varying aliases / avatars / etc */
- const selfUser = {};
- // The ID of the room you're talking in.
- let chatPaneRoomID = null;
- // What a MONSTER of a variable name! Tells whether we should switch to the
- // chat represented by the location hash (e.g. #prpl-jabber|a/|b). This is
- // true only if there even is a location.hash, and then only true until we
- // switch to a chat (e.g. because we found the chat, or the user interacted
- // before so).
- let shouldOpenChatBasedOnInitialHash = location.hash.length > 1;
- const removeChildren = function(element) {
- // Does not mutate the element - returns a new element!
- const cNode = element.cloneNode(false);
- element.parentElement.replaceChild(cNode, element);
- return cNode;
- };
- const isMobileView = () => window.matchMedia(`(min-width: ${MOBILE_WIDTH}px)`);
- const getRoomType = function(id) {
- if (id in chatDetailsDict) {
- return 'chat';
- } else if (id in userDetailsDict) {
- return 'buddy';
- } else {
- console.warn('Called getRoomType() on a room which is not stored as a chat nor a user: ' + id);
- console.trace();
- return null;
- }
- };
- const adjustHistoryOnJoin = function(id) {
- // OoOOoOO, Spooky history tricks!
- if (location.hash !== '#' + id) {
- // Long ago, this was an elegant, single line of code. But everything
- // changed when the Progressive Web Apps attacked. Only the Hacker,
- // master of all four use cases, could sate them, but when the world
- // needed her most, she vanished.
- const newState = {};
- if (isMobileView() && location.hash.length <= 1) {
- newState.openedFromMobileIndex = true;
- }
- history.pushState(newState, null, '#' + id);
- }
- // Now that we've opened a chat, we don't want to automatically switch
- // according to the #hash value again.
- shouldOpenChatBasedOnInitialHash = false;
- };
- const switchPaneRoomID = function(id) {
- /* Don't try to double switch */
- if (id === chatPaneRoomID) {
- /* We need to switch for mobile anyway */
- hideBuddyPane();
- /* And we need to adjust history anyway */
- adjustHistoryOnJoin(id);
- return;
- }
- const oldChatPaneRoomID = chatPaneRoomID;
- chatPaneRoomID = id;
- // Clear the list of unread messages, stored on the backend, if there are
- // any unread messages. (If there aren't any, there's no need to clear the
- // list - it's already empty!)
- const roomDetails = getRoomDetails(id);
- if (roomDetails.unread) {
- window.backendMarkAsRead(id);
- }
- // Also reset the unread count of this room, so that it isn't highlighted
- // as having unread messages in the buddy list.
- roomDetails.unread = 0;
- updateUnreadIcon();
- // Update classes of affected nodes.
- if (oldChatPaneRoomID) {
- updateRoomClass(oldChatPaneRoomID, 'selected');
- }
- updateRoomClass(id, 'selected');
- updateRoomClass(id, 'unread');
- // Make sure the buddy element is scrolled into view
- const roomEl = getBuddyOrChatEl(id);
- try {
- roomEl.scrollIntoView({behavior: 'instant', block: 'nearest'});
- } catch (e) {
- /* Old Firefox >_> */
- console.log('Suppressing scrolling exception (modern scrollIntoView not supported)');
- }
- // Remove any existing message elements, invalidate the group, then show any messages from that
- // chat.
- /* https://stackoverflow.com/question/3955229/remove-all-child-elements-of-a-dom-node-in-javascript,
- * answer by Maciej Gurban */
- messageList = removeChildren(messageList);
- // Display messages without flushing to improve batch performance
- lastGroup.author = null;
- for (const message of roomDetails.messages) {
- displayMessage(message, false);
- }
- // Then flush at the end.
- flushMessageGroup();
- // Scroll to the bottom. We need to do this once everything has rendered
- // in the browser, so use setTimeout() to run it immediately next "tick"
- // of JavaScript (which will be after the browser has rendered).
- setTimeout(() => {
- scrollToBottom(true);
- });
- // Switch the typing indicator to that of the new pane.
- updateTypingPane();
- // History magic
- adjustHistoryOnJoin(id);
- // Update room info bar.
- updateRoomInfoElement(id);
- // Do visual updates for mobile layout.
- hideBuddyPane();
- // If we selected a chat, emit that we've now actually joined it.
- if (getRoomType(id) === 'chat') {
- window.backendJoinChat(id);
- // Fill in the chat user list, too.
- chatUserList = removeChildren(chatUserList);
- const chatDetails = chatDetailsDict[id];
- const { users } = chatDetails;
- for (const userID of users.map(u => u.id)) {
- const userDetails = userDetailsDict[userID];
- displayBuddyOrChatItem({
- // Don't set an href - we don't want clicking this user to
- // do anything.
- html: tmplUser(userDetails, {href: ''}),
- listEl: chatUserList,
- details: userDetails,
- alias: userDetails.alias,
- className: 'user',
- // Since chat users aren't grouped, we want to effectively use
- // the list element as the group element itself.
- groupEl: chatUserList
- });
- }
- // Also mark on the DOM that we're viewing a chat - this will cause
- // some CSS changes.
- app.classList.add('chat');
- } else {
- app.classList.remove('chat');
- }
- };
- const updateRoomInfoElement = function(id) {
- roomInfoContent = removeChildren(roomInfoContent);
- if (getRoomType(id) === 'chat') {
- roomInfoContent.innerHTML = tmplChatRoomHeader(chatDetailsDict[id]);
- } else if (getRoomType(id) === 'buddy') {
- roomInfoContent.innerHTML = tmplBuddyRoomHeader(userDetailsDict[id]);
- }
- };
- const updateUnreadIcon = function(x) {
- /* Supplied to save time looking for unread messages */
- let href = '';
- if (x || Object.keys(roomDetailsDict).some(id => roomDetailsDict[id].unread)) {
- href = 'icons/menu.svg#menu-unread';
- } else {
- href = 'icons/menu.svg#menu';
- }
- roomBackButton.lastElementChild.innerHTML = tmplIcon({href: href});
- };
- const offsetBuddyListIndex = function(amount) {
- let allRoomIDs;
- if (getRoomType(chatPaneRoomID) === 'buddy') {
- const allBuddyEls = Array.from(buddyList.getElementsByClassName('user'));
- allRoomIDs = allBuddyEls.map(el => el.dataset.id);
- } else {
- const allChatEls = Array.from(chatList.getElementsByClassName('chat'));
- allRoomIDs = allChatEls.map(el => el.dataset.id);
- }
- const currentIndex = allRoomIDs.indexOf(chatPaneRoomID);
- let newIndex = currentIndex + amount;
- newIndex = Math.min(allRoomIDs.length - 1, newIndex);
- newIndex = Math.max(0, newIndex);
- const newRoomID = allRoomIDs[newIndex];
- switchPaneRoomID(newRoomID);
- };
- const getRoomDetails = function(id) {
- if (!(id in roomDetailsDict)) {
- roomDetailsDict[id] = {
- messages: [],
- unread: 0
- };
- }
- return roomDetailsDict[id];
- };
- const updateUserDetailsDict = function(newDetails) {
- const {id} = newDetails;
- if (id in userDetailsDict) {
- Object.assign(userDetailsDict[id], newDetails);
- } else {
- userDetailsDict[id] = newDetails;
- }
-
- userDetailsDict[id] = processUser(userDetailsDict[id]);
- if (userDetailsDict[id].isBuddy) {
- displayBuddy(id);
- }
- };
- const updateChatDetailsDict = function(newDetails) {
- const {id, users = []} = newDetails;
- if (id in chatDetailsDict) {
- Object.assign(chatDetailsDict[id], newDetails);
- } else {
- chatDetailsDict[id] = newDetails;
- }
- // Record any present users in userDetailsDict.
- for (const userDetails of users) {
- if (!(userDetails.id in userDetailsDict)) {
- /* XXX: Get rid of that condition. The issue is that we're
- * preempting the buddy aliases (nondeterministically?) and it's
- * problematic */
- updateUserDetailsDict(userDetails);
- }
- }
- displayChat(id);
- };
- const onReceiveUserJoinedChat = function(data) {
- for (const member of data.members) {
- const userDetails = userDetailsDict[member.id];
- updateUserDetailsDict({
- id: member.id,
- alias: userDetails && userDetails.alias || member.alias,
- chat: data.chat,
- isBuddy: false
- });
- }
- };
- const getBuddyOrChatEl = function(id, listEl = null) {
- const query = `[data-id="${id}"]`;
- if (listEl) {
- return listEl.querySelector(query);
- } else {
- return buddyList.querySelector(query) || chatList.querySelector(query);
- }
- };
- const getBuddyOrChatGroupEl = function(title, listEl) {
- // NB: This DOES take a listEl!
- return listEl.querySelector(`.group[data-title="${title}"]`);
- };
- const displayBuddyOrChatItem = function({html, listEl, details, alias, className, groupEl = null}) {
- const {id, group} = details;
- const itemHTML = html;
- const oldEl = getBuddyOrChatEl(id, listEl);
- if (oldEl) {
- // If there's already an element for this item, just replace it.
- oldEl.insertAdjacentHTML('beforebegin', itemHTML);
- oldEl.remove();
- return;
- }
- // If no groupEl was passed, find the group to put the item in.
- // If there is none, create it.
- if (!groupEl) {
- groupEl = getBuddyOrChatGroupEl(group, listEl);
- if (!groupEl) {
- const groupHTML = tmplSidebarGroup({title: group});
- listEl.insertAdjacentHTML('beforeend', groupHTML);
- groupEl = getBuddyOrChatGroupEl(group, listEl);
- }
- }
- const children = Array.from(groupEl.children);
- // Start at the first element that matches the given className.
- // This is so that we skip past, for example, the group heading.
- const startIndex = children.findIndex(el => el.classList.contains(className));
- // Find the index before which we should insert the new element.
- const aliasA = alias.toLowerCase();
- let i = startIndex;
- if (startIndex >= 0) {
- i = children.slice(startIndex).findIndex(element => {
- const aliasB = element.dataset.alias.toLowerCase();
- return aliasB > aliasA;
- });
- if (i >= 0) {
- i += startIndex;
- }
- }
- // If the index of the element we searched for is at least zero,
- // we found a result, so add the new buddy before that element.
- if (i >= 0) {
- children[i].insertAdjacentHTML('beforebegin', itemHTML);
- } else {
- // Otherwise, it means that every existing buddy should be
- // positioned before this new one (or there just aren't any
- // existing buddies yet), so add the new buddy to the end of
- // the list.
- groupEl.insertAdjacentHTML('beforeend', itemHTML);
- }
- };
- const displayBuddy = function(id) {
- const userDetails = userDetailsDict[id];
- displayBuddyOrChatItem({
- // Set an href - when this hash is opened (e.g. in a new tab or from a
- // bookmark), it will load this particular buddy's IM chat.
- html: tmplUser(userDetails, {href: '#' + id}),
- listEl: buddyList,
- details: userDetails,
- alias: userDetails.alias,
- className: 'user'
- });
- updateRoomClass(id, 'unread');
- updateRoomClass(id, 'selected');
- updateRoomClass(id, 'typing');
- };
- const displayChat = function(id) {
- const chatDetails = chatDetailsDict[id];
- displayBuddyOrChatItem({
- html: tmplChat(chatDetails),
- listEl: chatList,
- details: chatDetails,
- alias: chatDetails.name,
- className: 'chat'
- });
- };
- /* Updates classes on a buddy object without destroying the whole thing */
- const needsClass = function(id, classname) {
- if (classname === 'unread')
- return getRoomDetails(id).unread > 0;
- else if (classname === 'selected')
- return id === chatPaneRoomID;
- else if (classname === 'typing')
- return typingStatus[id] > 0;
- else
- return false;
- };
- const updateRoomClass = function(id, classname) {
- const needed = needsClass(id, classname);
- const el = getBuddyOrChatEl(id);
- if (needed)
- el.classList.add(classname);
- else
- el.classList.remove(classname);
- };
- /* State for last group, to avoid expensive DOM queries while writing messages.
- * Further, we don't even -flush- to the DOM until we really have to */
- const lastGroup = {
- author: null,
- container: null,
- queued_html: '',
- };
- const flushMessageGroup = function() {
- if (lastGroup.queued_html.length > 0 && lastGroup.container) {
- lastGroup.container.insertAdjacentHTML('beforeend', lastGroup.queued_html);
- lastGroup.queued_html = '';
- }
- };
- /* Applies /me to a message body */
- const meify = function(author, msg) {
- if (!msg.startsWith('/me '))
- return msg;
- const sans_me = msg.slice('/me '.length);
- return `<em>${author.alias} ${sans_me}</em>`;
- };
- const displayMessage = function(message, flush) {
- const isOurMessage = message.flags & PURPLE_MESSAGE_SEND;
- let authorDetails;
- if (isOurMessage && message.chat) {
- const chatDetails = chatDetailsDict[message.chat];
- const accountID = chatDetails.account;
- authorDetails = selfUser[accountID];
- } else if (message.who === 'system') {
- // TODO: System messages... or not. For now, ignore them.
- return;
- } else {
- const friendDetails = userDetailsDict[message.who || message.buddy];
- if (friendDetails) {
- const accountID = friendDetails.account;
- authorDetails = isOurMessage ? selfUser[accountID] : friendDetails;
- } else {
- /* Unknown user, make something up */
- authorDetails = processUser({
- alias: message.alias
- });
- }
- }
- /* Process the message content. This must be client-side to support
- * end-to-end encryption */
- const meified = meify(authorDetails, message.content);
- const sanitized = window.sanitizeHtmlString(meified);
- const messageHTML = tmplMessage(Object.assign({}, message, {
- authorDetails,
- contentHTML: sanitized,
- }));
- if (lastGroup.author !== authorDetails.id) {
- /* Flush the old group if we haven't already */
- flushMessageGroup();
- /* Create a new group, with the message at the same time to amortize the cost of creating the group */
- const groupDetails = {authorDetails};
- groupDetails.initial = messageHTML;
- const groupHTML = tmplMessageGroup(groupDetails);
- messageList.insertAdjacentHTML('beforeend', groupHTML);
- lastGroup.author = authorDetails.id;
- lastGroup.container = messageList.lastElementChild.querySelector('.message-group-content');
- } else {
- /* Otherwise, reuse the existing group, appending to the HTML queue */
- lastGroup.queued_html += messageHTML;
- }
- /* For normal (non-replay), flush immediately */
- if (flush) {
- flushMessageGroup();
- }
- };
- /* Enables OTR with a buddy if it's not already, returning the OTR obejct */
- const getBuddyOTR = (buddy) => {
- /* Check for OTR */
- const roomDetails = getRoomDetails(buddy);
- if (!roomDetails.otr) {
- /* Initialize OTR with this person */
- roomDetails.otr = new window.OTR(otrOptions);
- /* Set aggressive policy for now */
- roomDetails.otr.REQUIRE_ENCRYPTION = false;
- roomDetails.otr.ALLOW_V3 = false;
- roomDetails.otr.SEND_WHITESPACE_TAG = true;
- roomDetails.otr.WHITESPACE_START_AKE = true;
- roomDetails.otr.on('io', (msg, meta) => {
- /* We got a message to pass directly to the socket */
- console.log('->' + msg, meta);
- window.backendMessage(buddy, msg);
- });
- roomDetails.otr.on('ui', (msg, encrypted, meta) => {
- console.log('<- ' + msg, encrypted, meta);
- /* We got a (decrypted?) message, hooray */
- onReceiveMessageDisplay({
- flags: 0,
- buddy: buddy,
- content: msg
- }, true);
- });
- roomDetails.otr.on('status', function (state) {
- console.log(state);
- });
- roomDetails.otr.on('error', (err, sev) => {
- console.error(err, sev);
- });
- }
- return roomDetails.otr;
- };
- const onReceiveMessage = function(message, flush) {
- if (message.chat) {
- /* mpOTR not supported */
- onReceiveMessageDisplay(message, flush);
- } else if (message.buddy) {
- if (message.flags & PURPLE_MESSAGE_SEND) {
- /* Carbon of a message we sent, either on our machine or
- * elsewhere. If it's OTR, we ignore it since it effectively
- * doesn't exist. If it's plain-text, pass is through as is */
- if (!message.content.includes('?OTR')) {
- onReceiveMessageDisplay(message, flush);
- }
- } else {
- /* Message received, feed through OTR */
- getBuddyOTR(message.buddy).receiveMsg(message.content);
- }
- }
- };
- const onReceiveMessageDisplay = function(message, flush) {
- const isOurMessage = message.flags & PURPLE_MESSAGE_SEND;
- const roomDetails = getRoomDetails(message.chat || message.buddy);
- roomDetails.messages.push(message);
- // If we're currently viewing the chat that this message was sent in,
- // immediately display the message.
- if (message.chat === chatPaneRoomID || message.buddy === chatPaneRoomID) {
- if (flush) {
- const wasScrolledToBottom = isScrolledToBottom(messageList);
- displayMessage(message, true);
- scrollToBottom(wasScrolledToBottom);
- } else {
- displayMessage(message, false);
- }
- } else if (!isOurMessage) {
- // Otherwise, update the unread count - but only if WE didn't send the
- // message! (That could happen if the message was sent from a different
- // client.)
- roomDetails.unread++;
- updateRoomClass(message.chat || message.buddy, 'unread');
- updateUnreadIcon(true);
- }
- };
- function typingStatusToString(stat) {
- /* TODO: i18n */
- if (stat === TYPING_TYPING) return 'is typing...';
- if (stat === TYPING_STOPPED) return 'has stopped typing.';
- return '';
- }
- /* User -> (int) state */
- const typingStatus = {};
- /* Updates the active pane to display a typing indicator */
- const updateTypingPane = function() {
- // TODO: Support for group chats, somehow
- if (getRoomType(chatPaneRoomID) !== 'buddy') {
- return;
- }
- const buddy = userDetailsDict[chatPaneRoomID];
- const typingState = typingStatus[chatPaneRoomID];
- const description = typingStatusToString(typingState);
- typingIndicatorName.innerText = typingState ? buddy.alias : '';
- typingIndicatorStatus.innerText = description;
- };
- const onTyping = function(details) {
- /* Typing indication occurs in two phases. First, we record the typing
- * status. Second, we actuate it in the GUI if relevant. */
- typingStatus[details.buddy] = details.state;
- /* Splonk it on the buddy list so CSS can do its thing */
- updateRoomClass(details.buddy, 'typing');
- /* If this buddy is the active pane, update the indicator. */
- if (chatPaneRoomID === details.buddy)
- updateTypingPane();
- };
- /* Transforms user details from the network to additionally include derived
- * properties suitable for templating */
- const processUser = function(details) {
- return Object.assign(details, {
- avatar: window.getAvatar(details)
- });
- };
- const onReceiveUserDetails = function(details) {
- updateUserDetailsDict(Object.assign(details, {isBuddy: true}));
- if (shouldOpenChatBasedOnInitialHash) {
- openChatByHash();
- }
- };
- const onReceiveChatDetails = function(details) {
- updateChatDetailsDict(details);
- if (shouldOpenChatBasedOnInitialHash) {
- openChatByHash();
- }
- };
- /* A single account was received. From this, we can construct the "shadow" user
- * object */
- const onReceiveAccount = function(account) {
- selfUser[account.id] = processUser(account);
- };
- const onReceiveBuddyStatus = function(data) {
- updateUserDetailsDict({
- id: data.buddy,
- status: data.status
- });
- if (data.buddy === chatPaneRoomID) {
- updateRoomInfoElement(data.buddy);
- }
- };
- const onReceiveChatTopic = function(data) {
- updateChatDetailsDict({
- id: data.chat,
- topic: data.topic
- });
- if (data.chat === chatPaneRoomID) {
- updateRoomInfoElement(data.chat);
- }
- };
- const isScrolledToBottom = function(element) {
- const targetY = element.scrollHeight - element.clientHeight;
- const currentY = element.scrollTop;
- const difference = targetY - currentY;
- return difference < 100;
- };
- const scrollToBottom = function(wasScrolledToBottom) {
- const messageEl = messageListContainer.lastElementChild;
- if (wasScrolledToBottom) {
- messageEl.scrollIntoView({behavior: 'instant', block: 'end'});
- }
- };
- /* Typing indicators, TODO: Less bad, timers, stopped */
- let typingState = TYPING_NONE;
- function sendTypingState(state) {
- /* Don't bug the server for sporadic calls */
- if (typingState === state) {
- return;
- }
- /* TODO: Buddy list, statefulness */
- window.backendTyping(chatPaneRoomID, state);
- typingState = state;
- }
- /* Make focus not janky, since when I start typing a message I expect to be
- * able to start, you know, typing a message */
- const KEY_TAB = 9;
- const KEY_ENTER = 13;
- const KEY_SHIFT = 16;
- const KEY_ESCAPE = 27;
- const KEY_UP = 38;
- const KEY_DOWN = 40;
- const KEY_V = 86;
- const shouldFocusTextInput = function(e) {
- // Don't need to focus it if it's already focused!
- if (document.activeElement === chatTextInput) return false;
- // Capture ctrl-V (paste):
- if ((e.ctrlKey || e.metaKey) && e.which === KEY_V) return true;
- // Don't capture special keys:
- if (e.ctrlKey || e.metaKey || e.altKey) return false;
- // Don't capture tab or enter (by itself):
- if (e.which === KEY_TAB || e.which === KEY_ENTER) return false;
- // Don't capture shift-<nothing>, but do capture anything else <shift>:
- if (e.which === KEY_SHIFT) return false;
- return true;
- };
- const handleKeyDown = function(event) {
- if (event.which === KEY_DOWN && event.altKey) {
- offsetBuddyListIndex(+1);
- return;
- } else if (event.which === KEY_UP && event.altKey) {
- offsetBuddyListIndex(-1);
- return;
- }
- if (shouldFocusTextInput(event)) {
- chatTextInput.focus();
- }
- };
- buddyList.addEventListener('keydown', handleKeyDown);
- messageListContainer.addEventListener('keydown', handleKeyDown);
- chatTextInput.addEventListener('keydown', event => {
- handleKeyDown(event);
- if (event.keyCode === KEY_ENTER) {
- if (!event.shiftKey) {
- event.preventDefault();
- submitChatMessage();
- }
- }
- });
- const updateInputHeight = function() {
- const inp = chatTextInput;
- inp.style.height = 'auto';
- const style = window.getComputedStyle(inp, null);
- const rect = inp.getBoundingClientRect();
- const padding = rect.height - parseInt(style.getPropertyValue('height'));
- inp.style.height = (inp.scrollHeight - padding) + 'px';
- };
- updateInputHeight();
- chatTextInput.addEventListener('input', updateInputHeight);
- /* Use a global buddy list handler via bubbling, rather than binding to each
- * buddy individually (slow) */
- const findParentWithClass = function(e, className) {
- const path = e.path || (e.composedPath && e.composedPath());
- if (path) {
- return path.find(l => l.classList && l.classList.contains(className));
- } else {
- /* Shim for older browsers */
- let target = e.target;
- while (target && !target.classList.contains(className)) {
- console.log(target);
- target = target.parentNode;
- }
- return target;
- }
- };
- const roomListSwitchHandler = function(e, className) {
- // Prevent "flashing" from the underlying anchor
- e.preventDefault();
- // Only switch if this was a primary-button click without any key modifiers
- if (e.button && e.button !== 0)
- return;
- if (e.ctrlKey || e.shiftKey || e.metaKey)
- return;
- const el = findParentWithClass(e, className);
- if (el) {
- switchPaneRoomID(el.dataset.id);
- }
- };
- const buddyListSwitchHandler = function(event) {
- roomListSwitchHandler(event, 'user');
- };
- const chatListSwitchHandler = function(event) {
- roomListSwitchHandler(event, 'chat');
- };
- buddyList.addEventListener('mousedown', buddyListSwitchHandler);
- chatList.addEventListener('mousedown', chatListSwitchHandler);
- /* It's not necessary to bind to click, since it's already in the JavaScript
- * href via the hash change */
- chatTextInput.addEventListener('input', () => {
- const hasText = chatTextInput.value.length > 0;
- if (!typingState && hasText) {
- /* We've started typing */
- sendTypingState(TYPING_TYPING);
- } else if (typingState && !hasText) {
- /* We've stopped typing */
- sendTypingState(TYPING_NONE);
- }
- });
- const submitChatMessage = () => {
- const content = chatTextInput.value;
- chatTextInput.value = '';
- updateInputHeight();
- /* Obviously if we've wiped the input, we're done typing */
- sendTypingState(TYPING_NONE);
- const emojied = window.emojify(content);
- if (content.length) {
- if (getRoomType(chatPaneRoomID) === 'buddy') {
- /* Feed through OTR */
- const otr = getBuddyOTR(chatPaneRoomID);
- otr.sendMsg(emojied);
- /* Mirror for ourselves, if it'll be OTR-encrypted (such that we
- * need the manual carbon) */
- if (otr.msgstate === window.OTR.CONST.MSGSTATE_ENCRYPTED) {
- onReceiveMessageDisplay({
- flags: PURPLE_MESSAGE_SEND,
- buddy: chatPaneRoomID,
- content: emojied
- }, true);
- }
- } else {
- /* mpOTR not supported */
- window.backendMessage(chatPaneRoomID, emojied);
- }
- }
- };
- chatForm.addEventListener('submit', event => {
- event.preventDefault();
- submitChatMessage();
- });
- const showBuddyPane = function() {
- app.classList.add('show-buddy-list');
- app.classList.remove('show-chat-area');
- };
- const hideBuddyPane = function() {
- app.classList.remove('show-buddy-list');
- app.classList.add('show-chat-area');
- };
- const openChatByHash = function() {
- const id = decodeURIComponent(location.hash.slice(1));
- /* No buddy ID means we meant the buddy list, rather than any particular
- * buddy */
- if (id === '') {
- showBuddyPane();
- return;
- }
- /* Otherwise, open the room requested */
- if (id in userDetailsDict || id in chatDetailsDict) {
- switchPaneRoomID(id);
- }
- };
- window.addEventListener('popstate', openChatByHash);
- roomBackButton.addEventListener('click', event => {
- const state = history.state || {};
- if (state.openedFromMobileIndex) {
- history.back();
- } else {
- location.hash = '#';
- }
- event.preventDefault();
- });
- toggleChatUserListButton.addEventListener('click', event => {
- app.classList.toggle('show-chat-user-list');
- event.preventDefault();
- });
- /* Login screen */
- const showLoginScreen = function() {
- loginScreen.classList.add('visible');
- };
- const hideLoginScreen = function() {
- loginScreen.classList.remove('visible');
- };
- const showLoginForm = function() {
- loginForm.classList.add('visible');
- if (loginUsernameInput.value) {
- loginPasswordInput.focus();
- } else {
- loginUsernameInput.focus();
- }
- };
- const hideLoginForm = function() {
- loginForm.classList.remove('visible');
- };
- const showLoginStatus = function(text) {
- if (loginStatus.firstChild) {
- loginStatus.removeChild(loginStatus.firstChild);
- }
- loginStatus.appendChild(document.createTextNode(text));
- loginStatus.classList.add('visible');
- };
- const hideLoginStatus = function() {
- loginStatus.classList.remove('visible');
- };
- const onReceiveAuthError = function() {
- gone = false;
- showLoginScreen();
- showLoginStatus('Failed login. Try again?');
- showLoginForm();
- // Login failed, clear password.
- if (hasLocalStorage)
- localStorage.passwordHash = '';
- };
- const onReceiveAuthSuccess = function() {
- hideLoginScreen();
- hideLoginStatus();
- };
- let in_ratelimit = false;
- const onReceiveRatelimit = function(data) {
- /* Slow everything down */
- in_ratelimit = true;
- setTimeout(() => {
- console.log('Clear!\n');
- /* Rate limit is over! */
- in_ratelimit = false;
- /* We're now clear to try again, reveal we were wrong */
- onReceiveAuthError();
- }, data.milliseconds);
- };
- const onWebSocketClosed = (was_connected) => {
- gone = false;
- /* If we're being ratelimited, don't do anything, since we already know we
- * got closed up on */
- if (in_ratelimit)
- return;
- if (was_connected) {
- showLoginScreen();
- showLoginStatus('Lost connection. Try again soon?');
- showLoginForm();
- } else {
- showLoginScreen();
- showLoginStatus('Could not connect. Try again soon?');
- showLoginForm();
- }
- };
- loginForm.addEventListener('submit', event => {
- event.preventDefault();
- const password = loginPasswordInput.value;
- const username = loginUsernameInput.value;
- /* Clear the password */
- loginPasswordInput.value = '';
- window.hashPassword(password).then(passwordHash => {
- // Save the username and password. If the login fails, we'll clear them.
- if (hasLocalStorage) {
- localStorage.username = username;
- localStorage.passwordHash = passwordHash;
- }
- go(username, passwordHash);
- });
- });
- /* Modals */
- const makeAccountDropdown = function() {
- let html = '';
- for (const userDetails of Object.values(selfUser)) {
- html += tmplAccountDropdownOption({userDetails});
- }
- return tmplAccountDropdown({options: html});
- };
- const showModal = function(contentHTML, action) {
- // Modals are for showing forms; contentHTML should contain a <form>, or
- // else a lot of the code here won't work.
- modalScreen.classList.add('visible');
- modalContent.innerHTML = contentHTML;
- const form = modalContent.querySelector('form');
- form.addEventListener('submit', event => {
- event.preventDefault();
- action(form);
- });
- form.elements[0].focus();
- const cancelButton = modalContent.querySelector('input[name=cancel]');
- if (cancelButton) {
- cancelButton.addEventListener('click', hideModal);
- }
- };
- const hideModal = () => {
- modalScreen.classList.remove('visible');
- };
- // Hide the modal when you click the modal screen..
- modalScreen.addEventListener('click', hideModal);
- // ..unless you clicked on the content.
- modalContent.addEventListener('click', event => event.stopPropagation());
- // Also hide the modal when you press escape.
- document.body.addEventListener('keydown', event => {
- if (event.which === KEY_ESCAPE) {
- hideModal();
- }
- });
- addBuddyButton.addEventListener('click', () => {
- const html = window.templates.modalAddBuddy({
- accountDropdown: makeAccountDropdown()
- });
- showModal(html, form => {
- const account = form.querySelector('[name=account]').value;
- const username = form.querySelector('[name=username]').value;
- window.backendRequestBuddy(account, username, username, 'Let\'s be sapphi(DEL)c(/DEL)re buddies! <3');
- hideModal();
- });
- });
- let changeAvatarModalAccount = null;
- const showChangeAvatar = function(accountDetails) {
- const html = window.templates.modalChangeAvatar({
- accountDetails
- });
- showModal(html, form => {
- const { files } = form.querySelector('[name=file]');
- const file = files[0];
- const reader = new FileReader();
- reader.onload = () => {
- const base64url = reader.result;
- // The base64 URL will start with a data: prefix. We want to send
- // just the data, so remove the prefix.
- const data = base64url.split(',')[1];
- // Set this so we can automatically close the modal once we receive
- // the changeAvatar event for this account. We set it only now that
- // we're actually uploading the new avatar. This is to deal with
- // the corner case of having this modal open while you change your
- // avatar externally (i.e. a separate client/instance/browser tab).
- // The modal on *this* instance - the one where you HAVEN'T yet
- // changed your avatar - won't close.
- changeAvatarModalAccount = accountDetails.id;
- window.backendChangeAvatar(accountDetails.id, data);
- };
- reader.onerror = error => {
- // TODO: Error handling here
- console.error(error);
- };
- reader.readAsDataURL(file);
- });
- };
- changeAvatarButton.addEventListener('click', () => {
- // TODO: Show a list of users.
- const accountDetails = Object.values(selfUser)[0];
- showChangeAvatar(accountDetails);
- });
- const onReceiveChangeAvatar = function(data) {
- // Update the account in userDetailsDict and/or selfUser, making note that
- // the user now has an avatar set if they didn't before.
- const account = data.id;
- if (userDetailsDict[account]) {
- // TODO: Rename this to "hasAvatar".
- userDetailsDict[account].hasIcon = true;
- processUser(userDetailsDict[account]);
- }
- if (selfUser[account]) {
- selfUser[account].hasIcon = true;
- processUser(selfUser[account]);
- }
- // Close the change avatar modal if this is the account that had its avatar
- // changed.
- if (account === changeAvatarModalAccount) {
- hideModal();
- changeAvatarModalAccount = null;
- }
- };
- /* Initialize event handlers */
- window.backendHooks.message = (d) => { onReceiveMessage(d, true); };
- window.backendHooks.batchMessage = (d) => { onReceiveMessage(d, false); };
- window.backendHooks.flushBatched = () => { flushMessageGroup(); scrollToBottom(true); };
- window.backendHooks.typing = onTyping;
- window.backendHooks.newBuddy = onReceiveUserDetails;
- window.backendHooks.newChat = onReceiveChatDetails;
- window.backendHooks.newAccount = onReceiveAccount;
- window.backendHooks.changeAvatar = onReceiveChangeAvatar;
- window.backendHooks.joined = onReceiveUserJoinedChat;
- window.backendHooks.buddyStatus = onReceiveBuddyStatus;
- window.backendHooks.topic = onReceiveChatTopic;
- window.backendHooks.autherror = onReceiveAuthError;
- window.backendHooks.authsuccess = onReceiveAuthSuccess;
- window.backendHooks.ratelimit = onReceiveRatelimit;
- window.backendHooks.wsclosed = onWebSocketClosed;
- /* Fire away */
- let gone = false;
- const go = function(username, passwordHash) {
- if (!gone) {
- showLoginScreen();
- showLoginStatus('Logging in...');
- hideLoginForm();
- window.backendConnect(username, passwordHash);
- gone = true;
- }
- };
- /* We need to check for localstorage since, although it exists in all of our
- * target browsers, it may not be allowed per the content security settings
- * (e.g. in Chromium); attempting to access it otherwise raises a security
- * error. That said, a mere 'localStorage' in window check will not reveal this
- * issue; only an attempt to access it. -AR */
- let hasLocalStorage = false;
- try {
- localStorage;
- hasLocalStorage = true;
- } catch (e) {
- console.warn('WARNING: No local storage...\n');
- console.warn(e);
- }
- const storedPasswordHash = hasLocalStorage ? localStorage.passwordHash : null;
- const storedUsername = hasLocalStorage ? localStorage.username : null;
- if (storedUsername && storedPasswordHash) {
- // TODO: Error handling. What if the password is wrong?
- go(storedUsername, storedPasswordHash);
- } else {
- showLoginScreen();
- showLoginForm();
- }
- //navigator.serviceWorker.register("src/service-worker.js");
|