main.js 39 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321
  1. /*
  2. * Sapphire
  3. *
  4. * Copyright (C) 2018 Florrie Haero
  5. * Copyright (C) 2018 Alyssa Rosenzweig
  6. * Copyright (C) 2018 eq
  7. *
  8. * This program is free software; you can redistribute it and/or modify
  9. * it under the terms of the GNU General Public License as published by
  10. * the Free Software Foundation; either version 2 of the License, or
  11. * (at your option) any later version.
  12. *
  13. * This program is distributed in the hope that it will be useful,
  14. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  15. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  16. * GNU General Public License for more details.
  17. *
  18. * You should have received a copy of the GNU General Public License
  19. * along with this program; if not, write to the Free Software
  20. * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02111-1301 USA
  21. *
  22. */
  23. 'use strict';
  24. let otrKey = null;
  25. if (!localStorage.otrKey) {
  26. otrKey = new window.DSA();
  27. localStorage.otrKey = otrKey.packPrivate();
  28. } else {
  29. otrKey = window.DSA.parsePrivate(localStorage.otrKey);
  30. }
  31. const otrTag = window.OTR.makeInstanceTag();
  32. const otrOptions = {
  33. fragment_size: 800,
  34. send_interval: 200,
  35. priv: otrKey,
  36. instance_tag: otrTag
  37. };
  38. const {
  39. accountDropdown: tmplAccountDropdown,
  40. accountDropdownOption: tmplAccountDropdownOption,
  41. buddyRoomHeader: tmplBuddyRoomHeader,
  42. chat: tmplChat,
  43. chatRoomHeader: tmplChatRoomHeader,
  44. icon: tmplIcon,
  45. message: tmplMessage,
  46. messageGroup: tmplMessageGroup,
  47. sidebarGroup: tmplSidebarGroup,
  48. user: tmplUser
  49. } = window.templates;
  50. const addBuddyButton = document.getElementById('add-buddy');
  51. const app = document.getElementById('app');
  52. const buddyList = document.getElementById('buddy-list');
  53. const changeAvatarButton = document.getElementById('change-avatar');
  54. const chatForm = document.getElementById('chat-form');
  55. const chatList = document.getElementById('chat-list');
  56. const chatTextInput = document.querySelector('[data-id="input"]');
  57. let chatUserList = document.getElementById('chat-user-list');
  58. const loginForm = document.getElementById('login-form');
  59. const loginPasswordInput = document.getElementById('password');
  60. const loginUsernameInput = document.getElementById('username');
  61. const loginScreen = document.getElementById('login-screen');
  62. const loginStatus = document.getElementById('login-status');
  63. let messageList = document.getElementById('message-list');
  64. const messageListContainer = document.getElementById('message-list-container');
  65. const modalScreen = document.getElementById('modal-screen');
  66. const modalContent = document.getElementById('modal-content');
  67. const roomBackButton = document.getElementById('room-back');
  68. let roomInfoContent = document.getElementById('room-info-content');
  69. const toggleChatUserListButton = document.getElementById('toggle-chat-user-list');
  70. const typingIndicatorName = document.getElementById('typing-indicator-name');
  71. const typingIndicatorStatus = document.getElementById('typing-indicator-status');
  72. const PROTOCOL = window.location.protocol === 'file:' ? 'http:' : window.location.protocol;
  73. const BASE_URL = window.BASE_HOST + ':7070';
  74. window.BASE_ICON = PROTOCOL + '//' + BASE_URL + '/icon/?name=';
  75. // Maximum width at which the mobile layout (buddy list in a separate view
  76. // from the main chat area) is displayed. MUST be equal to the value specified
  77. // in the @media in style.css.
  78. const MOBILE_WIDTH = 600;
  79. // Values from the API; DO NOT CHANGE without updating the server.
  80. const PURPLE_MESSAGE_SEND = 1;
  81. const TYPING_NONE = 0; // Must ALWAYS be falsey
  82. const TYPING_TYPING = 1;
  83. const TYPING_STOPPED = 2;
  84. const userDetailsDict = {};
  85. const chatDetailsDict = {};
  86. const roomDetailsDict = {};
  87. /* Shadow user object representing us, segregated by account to support
  88. * varying aliases / avatars / etc */
  89. const selfUser = {};
  90. // The ID of the room you're talking in.
  91. let chatPaneRoomID = null;
  92. // What a MONSTER of a variable name! Tells whether we should switch to the
  93. // chat represented by the location hash (e.g. #prpl-jabber|a/|b). This is
  94. // true only if there even is a location.hash, and then only true until we
  95. // switch to a chat (e.g. because we found the chat, or the user interacted
  96. // before so).
  97. let shouldOpenChatBasedOnInitialHash = location.hash.length > 1;
  98. const removeChildren = function(element) {
  99. // Does not mutate the element - returns a new element!
  100. const cNode = element.cloneNode(false);
  101. element.parentElement.replaceChild(cNode, element);
  102. return cNode;
  103. };
  104. const isMobileView = () => window.matchMedia(`(min-width: ${MOBILE_WIDTH}px)`);
  105. const getRoomType = function(id) {
  106. if (id in chatDetailsDict) {
  107. return 'chat';
  108. } else if (id in userDetailsDict) {
  109. return 'buddy';
  110. } else {
  111. console.warn('Called getRoomType() on a room which is not stored as a chat nor a user: ' + id);
  112. console.trace();
  113. return null;
  114. }
  115. };
  116. const adjustHistoryOnJoin = function(id) {
  117. // OoOOoOO, Spooky history tricks!
  118. if (location.hash !== '#' + id) {
  119. // Long ago, this was an elegant, single line of code. But everything
  120. // changed when the Progressive Web Apps attacked. Only the Hacker,
  121. // master of all four use cases, could sate them, but when the world
  122. // needed her most, she vanished.
  123. const newState = {};
  124. if (isMobileView() && location.hash.length <= 1) {
  125. newState.openedFromMobileIndex = true;
  126. }
  127. history.pushState(newState, null, '#' + id);
  128. }
  129. // Now that we've opened a chat, we don't want to automatically switch
  130. // according to the #hash value again.
  131. shouldOpenChatBasedOnInitialHash = false;
  132. };
  133. const switchPaneRoomID = function(id) {
  134. /* Don't try to double switch */
  135. if (id === chatPaneRoomID) {
  136. /* We need to switch for mobile anyway */
  137. hideBuddyPane();
  138. /* And we need to adjust history anyway */
  139. adjustHistoryOnJoin(id);
  140. return;
  141. }
  142. const oldChatPaneRoomID = chatPaneRoomID;
  143. chatPaneRoomID = id;
  144. // Clear the list of unread messages, stored on the backend, if there are
  145. // any unread messages. (If there aren't any, there's no need to clear the
  146. // list - it's already empty!)
  147. const roomDetails = getRoomDetails(id);
  148. if (roomDetails.unread) {
  149. window.backendMarkAsRead(id);
  150. }
  151. // Also reset the unread count of this room, so that it isn't highlighted
  152. // as having unread messages in the buddy list.
  153. roomDetails.unread = 0;
  154. updateUnreadIcon();
  155. // Update classes of affected nodes.
  156. if (oldChatPaneRoomID) {
  157. updateRoomClass(oldChatPaneRoomID, 'selected');
  158. }
  159. updateRoomClass(id, 'selected');
  160. updateRoomClass(id, 'unread');
  161. // Make sure the buddy element is scrolled into view
  162. const roomEl = getBuddyOrChatEl(id);
  163. try {
  164. roomEl.scrollIntoView({behavior: 'instant', block: 'nearest'});
  165. } catch (e) {
  166. /* Old Firefox >_> */
  167. console.log('Suppressing scrolling exception (modern scrollIntoView not supported)');
  168. }
  169. // Remove any existing message elements, invalidate the group, then show any messages from that
  170. // chat.
  171. /* https://stackoverflow.com/question/3955229/remove-all-child-elements-of-a-dom-node-in-javascript,
  172. * answer by Maciej Gurban */
  173. messageList = removeChildren(messageList);
  174. // Display messages without flushing to improve batch performance
  175. lastGroup.author = null;
  176. for (const message of roomDetails.messages) {
  177. displayMessage(message, false);
  178. }
  179. // Then flush at the end.
  180. flushMessageGroup();
  181. // Scroll to the bottom. We need to do this once everything has rendered
  182. // in the browser, so use setTimeout() to run it immediately next "tick"
  183. // of JavaScript (which will be after the browser has rendered).
  184. setTimeout(() => {
  185. scrollToBottom(true);
  186. });
  187. // Switch the typing indicator to that of the new pane.
  188. updateTypingPane();
  189. // History magic
  190. adjustHistoryOnJoin(id);
  191. // Update room info bar.
  192. updateRoomInfoElement(id);
  193. // Do visual updates for mobile layout.
  194. hideBuddyPane();
  195. // If we selected a chat, emit that we've now actually joined it.
  196. if (getRoomType(id) === 'chat') {
  197. window.backendJoinChat(id);
  198. // Fill in the chat user list, too.
  199. chatUserList = removeChildren(chatUserList);
  200. const chatDetails = chatDetailsDict[id];
  201. const { users } = chatDetails;
  202. for (const userID of users.map(u => u.id)) {
  203. const userDetails = userDetailsDict[userID];
  204. displayBuddyOrChatItem({
  205. // Don't set an href - we don't want clicking this user to
  206. // do anything.
  207. html: tmplUser(userDetails, {href: ''}),
  208. listEl: chatUserList,
  209. details: userDetails,
  210. alias: userDetails.alias,
  211. className: 'user',
  212. // Since chat users aren't grouped, we want to effectively use
  213. // the list element as the group element itself.
  214. groupEl: chatUserList
  215. });
  216. }
  217. // Also mark on the DOM that we're viewing a chat - this will cause
  218. // some CSS changes.
  219. app.classList.add('chat');
  220. } else {
  221. app.classList.remove('chat');
  222. }
  223. };
  224. const updateRoomInfoElement = function(id) {
  225. roomInfoContent = removeChildren(roomInfoContent);
  226. if (getRoomType(id) === 'chat') {
  227. roomInfoContent.innerHTML = tmplChatRoomHeader(chatDetailsDict[id]);
  228. } else if (getRoomType(id) === 'buddy') {
  229. roomInfoContent.innerHTML = tmplBuddyRoomHeader(userDetailsDict[id]);
  230. }
  231. };
  232. const updateUnreadIcon = function(x) {
  233. /* Supplied to save time looking for unread messages */
  234. let href = '';
  235. if (x || Object.keys(roomDetailsDict).some(id => roomDetailsDict[id].unread)) {
  236. href = 'icons/menu.svg#menu-unread';
  237. } else {
  238. href = 'icons/menu.svg#menu';
  239. }
  240. roomBackButton.lastElementChild.innerHTML = tmplIcon({href: href});
  241. };
  242. const offsetBuddyListIndex = function(amount) {
  243. let allRoomIDs;
  244. if (getRoomType(chatPaneRoomID) === 'buddy') {
  245. const allBuddyEls = Array.from(buddyList.getElementsByClassName('user'));
  246. allRoomIDs = allBuddyEls.map(el => el.dataset.id);
  247. } else {
  248. const allChatEls = Array.from(chatList.getElementsByClassName('chat'));
  249. allRoomIDs = allChatEls.map(el => el.dataset.id);
  250. }
  251. const currentIndex = allRoomIDs.indexOf(chatPaneRoomID);
  252. let newIndex = currentIndex + amount;
  253. newIndex = Math.min(allRoomIDs.length - 1, newIndex);
  254. newIndex = Math.max(0, newIndex);
  255. const newRoomID = allRoomIDs[newIndex];
  256. switchPaneRoomID(newRoomID);
  257. };
  258. const getRoomDetails = function(id) {
  259. if (!(id in roomDetailsDict)) {
  260. roomDetailsDict[id] = {
  261. messages: [],
  262. unread: 0
  263. };
  264. }
  265. return roomDetailsDict[id];
  266. };
  267. const updateUserDetailsDict = function(newDetails) {
  268. const {id} = newDetails;
  269. if (id in userDetailsDict) {
  270. Object.assign(userDetailsDict[id], newDetails);
  271. } else {
  272. userDetailsDict[id] = newDetails;
  273. }
  274. userDetailsDict[id] = processUser(userDetailsDict[id]);
  275. if (userDetailsDict[id].isBuddy) {
  276. displayBuddy(id);
  277. }
  278. };
  279. const updateChatDetailsDict = function(newDetails) {
  280. const {id, users = []} = newDetails;
  281. if (id in chatDetailsDict) {
  282. Object.assign(chatDetailsDict[id], newDetails);
  283. } else {
  284. chatDetailsDict[id] = newDetails;
  285. }
  286. // Record any present users in userDetailsDict.
  287. for (const userDetails of users) {
  288. if (!(userDetails.id in userDetailsDict)) {
  289. /* XXX: Get rid of that condition. The issue is that we're
  290. * preempting the buddy aliases (nondeterministically?) and it's
  291. * problematic */
  292. updateUserDetailsDict(userDetails);
  293. }
  294. }
  295. displayChat(id);
  296. };
  297. const onReceiveUserJoinedChat = function(data) {
  298. for (const member of data.members) {
  299. const userDetails = userDetailsDict[member.id];
  300. updateUserDetailsDict({
  301. id: member.id,
  302. alias: userDetails && userDetails.alias || member.alias,
  303. chat: data.chat,
  304. isBuddy: false
  305. });
  306. }
  307. };
  308. const getBuddyOrChatEl = function(id, listEl = null) {
  309. const query = `[data-id="${id}"]`;
  310. if (listEl) {
  311. return listEl.querySelector(query);
  312. } else {
  313. return buddyList.querySelector(query) || chatList.querySelector(query);
  314. }
  315. };
  316. const getBuddyOrChatGroupEl = function(title, listEl) {
  317. // NB: This DOES take a listEl!
  318. return listEl.querySelector(`.group[data-title="${title}"]`);
  319. };
  320. const displayBuddyOrChatItem = function({html, listEl, details, alias, className, groupEl = null}) {
  321. const {id, group} = details;
  322. const itemHTML = html;
  323. const oldEl = getBuddyOrChatEl(id, listEl);
  324. if (oldEl) {
  325. // If there's already an element for this item, just replace it.
  326. oldEl.insertAdjacentHTML('beforebegin', itemHTML);
  327. oldEl.remove();
  328. return;
  329. }
  330. // If no groupEl was passed, find the group to put the item in.
  331. // If there is none, create it.
  332. if (!groupEl) {
  333. groupEl = getBuddyOrChatGroupEl(group, listEl);
  334. if (!groupEl) {
  335. const groupHTML = tmplSidebarGroup({title: group});
  336. listEl.insertAdjacentHTML('beforeend', groupHTML);
  337. groupEl = getBuddyOrChatGroupEl(group, listEl);
  338. }
  339. }
  340. const children = Array.from(groupEl.children);
  341. // Start at the first element that matches the given className.
  342. // This is so that we skip past, for example, the group heading.
  343. const startIndex = children.findIndex(el => el.classList.contains(className));
  344. // Find the index before which we should insert the new element.
  345. const aliasA = alias.toLowerCase();
  346. let i = startIndex;
  347. if (startIndex >= 0) {
  348. i = children.slice(startIndex).findIndex(element => {
  349. const aliasB = element.dataset.alias.toLowerCase();
  350. return aliasB > aliasA;
  351. });
  352. if (i >= 0) {
  353. i += startIndex;
  354. }
  355. }
  356. // If the index of the element we searched for is at least zero,
  357. // we found a result, so add the new buddy before that element.
  358. if (i >= 0) {
  359. children[i].insertAdjacentHTML('beforebegin', itemHTML);
  360. } else {
  361. // Otherwise, it means that every existing buddy should be
  362. // positioned before this new one (or there just aren't any
  363. // existing buddies yet), so add the new buddy to the end of
  364. // the list.
  365. groupEl.insertAdjacentHTML('beforeend', itemHTML);
  366. }
  367. };
  368. const displayBuddy = function(id) {
  369. const userDetails = userDetailsDict[id];
  370. displayBuddyOrChatItem({
  371. // Set an href - when this hash is opened (e.g. in a new tab or from a
  372. // bookmark), it will load this particular buddy's IM chat.
  373. html: tmplUser(userDetails, {href: '#' + id}),
  374. listEl: buddyList,
  375. details: userDetails,
  376. alias: userDetails.alias,
  377. className: 'user'
  378. });
  379. updateRoomClass(id, 'unread');
  380. updateRoomClass(id, 'selected');
  381. updateRoomClass(id, 'typing');
  382. };
  383. const displayChat = function(id) {
  384. const chatDetails = chatDetailsDict[id];
  385. displayBuddyOrChatItem({
  386. html: tmplChat(chatDetails),
  387. listEl: chatList,
  388. details: chatDetails,
  389. alias: chatDetails.name,
  390. className: 'chat'
  391. });
  392. };
  393. /* Updates classes on a buddy object without destroying the whole thing */
  394. const needsClass = function(id, classname) {
  395. if (classname === 'unread')
  396. return getRoomDetails(id).unread > 0;
  397. else if (classname === 'selected')
  398. return id === chatPaneRoomID;
  399. else if (classname === 'typing')
  400. return typingStatus[id] > 0;
  401. else
  402. return false;
  403. };
  404. const updateRoomClass = function(id, classname) {
  405. const needed = needsClass(id, classname);
  406. const el = getBuddyOrChatEl(id);
  407. if (needed)
  408. el.classList.add(classname);
  409. else
  410. el.classList.remove(classname);
  411. };
  412. /* State for last group, to avoid expensive DOM queries while writing messages.
  413. * Further, we don't even -flush- to the DOM until we really have to */
  414. const lastGroup = {
  415. author: null,
  416. container: null,
  417. queued_html: '',
  418. };
  419. const flushMessageGroup = function() {
  420. if (lastGroup.queued_html.length > 0 && lastGroup.container) {
  421. lastGroup.container.insertAdjacentHTML('beforeend', lastGroup.queued_html);
  422. lastGroup.queued_html = '';
  423. }
  424. };
  425. /* Applies /me to a message body */
  426. const meify = function(author, msg) {
  427. if (!msg.startsWith('/me '))
  428. return msg;
  429. const sans_me = msg.slice('/me '.length);
  430. return `<em>${author.alias} ${sans_me}</em>`;
  431. };
  432. const displayMessage = function(message, flush) {
  433. const isOurMessage = message.flags & PURPLE_MESSAGE_SEND;
  434. let authorDetails;
  435. if (isOurMessage && message.chat) {
  436. const chatDetails = chatDetailsDict[message.chat];
  437. const accountID = chatDetails.account;
  438. authorDetails = selfUser[accountID];
  439. } else if (message.who === 'system') {
  440. // TODO: System messages... or not. For now, ignore them.
  441. return;
  442. } else {
  443. const friendDetails = userDetailsDict[message.who || message.buddy];
  444. if (friendDetails) {
  445. const accountID = friendDetails.account;
  446. authorDetails = isOurMessage ? selfUser[accountID] : friendDetails;
  447. } else {
  448. /* Unknown user, make something up */
  449. authorDetails = processUser({
  450. alias: message.alias
  451. });
  452. }
  453. }
  454. /* Process the message content. This must be client-side to support
  455. * end-to-end encryption */
  456. const meified = meify(authorDetails, message.content);
  457. const sanitized = window.sanitizeHtmlString(meified);
  458. const messageHTML = tmplMessage(Object.assign({}, message, {
  459. authorDetails,
  460. contentHTML: sanitized,
  461. }));
  462. if (lastGroup.author !== authorDetails.id) {
  463. /* Flush the old group if we haven't already */
  464. flushMessageGroup();
  465. /* Create a new group, with the message at the same time to amortize the cost of creating the group */
  466. const groupDetails = {authorDetails};
  467. groupDetails.initial = messageHTML;
  468. const groupHTML = tmplMessageGroup(groupDetails);
  469. messageList.insertAdjacentHTML('beforeend', groupHTML);
  470. lastGroup.author = authorDetails.id;
  471. lastGroup.container = messageList.lastElementChild.querySelector('.message-group-content');
  472. } else {
  473. /* Otherwise, reuse the existing group, appending to the HTML queue */
  474. lastGroup.queued_html += messageHTML;
  475. }
  476. /* For normal (non-replay), flush immediately */
  477. if (flush) {
  478. flushMessageGroup();
  479. }
  480. };
  481. /* Enables OTR with a buddy if it's not already, returning the OTR obejct */
  482. const getBuddyOTR = (buddy) => {
  483. /* Check for OTR */
  484. const roomDetails = getRoomDetails(buddy);
  485. if (!roomDetails.otr) {
  486. /* Initialize OTR with this person */
  487. roomDetails.otr = new window.OTR(otrOptions);
  488. /* Set aggressive policy for now */
  489. roomDetails.otr.REQUIRE_ENCRYPTION = false;
  490. roomDetails.otr.ALLOW_V3 = false;
  491. roomDetails.otr.SEND_WHITESPACE_TAG = true;
  492. roomDetails.otr.WHITESPACE_START_AKE = true;
  493. roomDetails.otr.on('io', (msg, meta) => {
  494. /* We got a message to pass directly to the socket */
  495. console.log('->' + msg, meta);
  496. window.backendMessage(buddy, msg);
  497. });
  498. roomDetails.otr.on('ui', (msg, encrypted, meta) => {
  499. console.log('<- ' + msg, encrypted, meta);
  500. /* We got a (decrypted?) message, hooray */
  501. onReceiveMessageDisplay({
  502. flags: 0,
  503. buddy: buddy,
  504. content: msg
  505. }, true);
  506. });
  507. roomDetails.otr.on('status', function (state) {
  508. console.log(state);
  509. });
  510. roomDetails.otr.on('error', (err, sev) => {
  511. console.error(err, sev);
  512. });
  513. }
  514. return roomDetails.otr;
  515. };
  516. const onReceiveMessage = function(message, flush) {
  517. if (message.chat) {
  518. /* mpOTR not supported */
  519. onReceiveMessageDisplay(message, flush);
  520. } else if (message.buddy) {
  521. if (message.flags & PURPLE_MESSAGE_SEND) {
  522. /* Carbon of a message we sent, either on our machine or
  523. * elsewhere. If it's OTR, we ignore it since it effectively
  524. * doesn't exist. If it's plain-text, pass is through as is */
  525. if (!message.content.includes('?OTR')) {
  526. onReceiveMessageDisplay(message, flush);
  527. }
  528. } else {
  529. /* Message received, feed through OTR */
  530. getBuddyOTR(message.buddy).receiveMsg(message.content);
  531. }
  532. }
  533. };
  534. const onReceiveMessageDisplay = function(message, flush) {
  535. const isOurMessage = message.flags & PURPLE_MESSAGE_SEND;
  536. const roomDetails = getRoomDetails(message.chat || message.buddy);
  537. roomDetails.messages.push(message);
  538. // If we're currently viewing the chat that this message was sent in,
  539. // immediately display the message.
  540. if (message.chat === chatPaneRoomID || message.buddy === chatPaneRoomID) {
  541. if (flush) {
  542. const wasScrolledToBottom = isScrolledToBottom(messageList);
  543. displayMessage(message, true);
  544. scrollToBottom(wasScrolledToBottom);
  545. } else {
  546. displayMessage(message, false);
  547. }
  548. } else if (!isOurMessage) {
  549. // Otherwise, update the unread count - but only if WE didn't send the
  550. // message! (That could happen if the message was sent from a different
  551. // client.)
  552. roomDetails.unread++;
  553. updateRoomClass(message.chat || message.buddy, 'unread');
  554. updateUnreadIcon(true);
  555. }
  556. };
  557. function typingStatusToString(stat) {
  558. /* TODO: i18n */
  559. if (stat === TYPING_TYPING) return 'is typing...';
  560. if (stat === TYPING_STOPPED) return 'has stopped typing.';
  561. return '';
  562. }
  563. /* User -> (int) state */
  564. const typingStatus = {};
  565. /* Updates the active pane to display a typing indicator */
  566. const updateTypingPane = function() {
  567. // TODO: Support for group chats, somehow
  568. if (getRoomType(chatPaneRoomID) !== 'buddy') {
  569. return;
  570. }
  571. const buddy = userDetailsDict[chatPaneRoomID];
  572. const typingState = typingStatus[chatPaneRoomID];
  573. const description = typingStatusToString(typingState);
  574. typingIndicatorName.innerText = typingState ? buddy.alias : '';
  575. typingIndicatorStatus.innerText = description;
  576. };
  577. const onTyping = function(details) {
  578. /* Typing indication occurs in two phases. First, we record the typing
  579. * status. Second, we actuate it in the GUI if relevant. */
  580. typingStatus[details.buddy] = details.state;
  581. /* Splonk it on the buddy list so CSS can do its thing */
  582. updateRoomClass(details.buddy, 'typing');
  583. /* If this buddy is the active pane, update the indicator. */
  584. if (chatPaneRoomID === details.buddy)
  585. updateTypingPane();
  586. };
  587. /* Transforms user details from the network to additionally include derived
  588. * properties suitable for templating */
  589. const processUser = function(details) {
  590. return Object.assign(details, {
  591. avatar: window.getAvatar(details)
  592. });
  593. };
  594. const onReceiveUserDetails = function(details) {
  595. updateUserDetailsDict(Object.assign(details, {isBuddy: true}));
  596. if (shouldOpenChatBasedOnInitialHash) {
  597. openChatByHash();
  598. }
  599. };
  600. const onReceiveChatDetails = function(details) {
  601. updateChatDetailsDict(details);
  602. if (shouldOpenChatBasedOnInitialHash) {
  603. openChatByHash();
  604. }
  605. };
  606. /* A single account was received. From this, we can construct the "shadow" user
  607. * object */
  608. const onReceiveAccount = function(account) {
  609. selfUser[account.id] = processUser(account);
  610. };
  611. const onReceiveBuddyStatus = function(data) {
  612. updateUserDetailsDict({
  613. id: data.buddy,
  614. status: data.status
  615. });
  616. if (data.buddy === chatPaneRoomID) {
  617. updateRoomInfoElement(data.buddy);
  618. }
  619. };
  620. const onReceiveChatTopic = function(data) {
  621. updateChatDetailsDict({
  622. id: data.chat,
  623. topic: data.topic
  624. });
  625. if (data.chat === chatPaneRoomID) {
  626. updateRoomInfoElement(data.chat);
  627. }
  628. };
  629. const isScrolledToBottom = function(element) {
  630. const targetY = element.scrollHeight - element.clientHeight;
  631. const currentY = element.scrollTop;
  632. const difference = targetY - currentY;
  633. return difference < 100;
  634. };
  635. const scrollToBottom = function(wasScrolledToBottom) {
  636. const messageEl = messageListContainer.lastElementChild;
  637. if (wasScrolledToBottom) {
  638. messageEl.scrollIntoView({behavior: 'instant', block: 'end'});
  639. }
  640. };
  641. /* Typing indicators, TODO: Less bad, timers, stopped */
  642. let typingState = TYPING_NONE;
  643. function sendTypingState(state) {
  644. /* Don't bug the server for sporadic calls */
  645. if (typingState === state) {
  646. return;
  647. }
  648. /* TODO: Buddy list, statefulness */
  649. window.backendTyping(chatPaneRoomID, state);
  650. typingState = state;
  651. }
  652. /* Make focus not janky, since when I start typing a message I expect to be
  653. * able to start, you know, typing a message */
  654. const KEY_TAB = 9;
  655. const KEY_ENTER = 13;
  656. const KEY_SHIFT = 16;
  657. const KEY_ESCAPE = 27;
  658. const KEY_UP = 38;
  659. const KEY_DOWN = 40;
  660. const KEY_V = 86;
  661. const shouldFocusTextInput = function(e) {
  662. // Don't need to focus it if it's already focused!
  663. if (document.activeElement === chatTextInput) return false;
  664. // Capture ctrl-V (paste):
  665. if ((e.ctrlKey || e.metaKey) && e.which === KEY_V) return true;
  666. // Don't capture special keys:
  667. if (e.ctrlKey || e.metaKey || e.altKey) return false;
  668. // Don't capture tab or enter (by itself):
  669. if (e.which === KEY_TAB || e.which === KEY_ENTER) return false;
  670. // Don't capture shift-<nothing>, but do capture anything else <shift>:
  671. if (e.which === KEY_SHIFT) return false;
  672. return true;
  673. };
  674. const handleKeyDown = function(event) {
  675. if (event.which === KEY_DOWN && event.altKey) {
  676. offsetBuddyListIndex(+1);
  677. return;
  678. } else if (event.which === KEY_UP && event.altKey) {
  679. offsetBuddyListIndex(-1);
  680. return;
  681. }
  682. if (shouldFocusTextInput(event)) {
  683. chatTextInput.focus();
  684. }
  685. };
  686. buddyList.addEventListener('keydown', handleKeyDown);
  687. messageListContainer.addEventListener('keydown', handleKeyDown);
  688. chatTextInput.addEventListener('keydown', event => {
  689. handleKeyDown(event);
  690. if (event.keyCode === KEY_ENTER) {
  691. if (!event.shiftKey) {
  692. event.preventDefault();
  693. submitChatMessage();
  694. }
  695. }
  696. });
  697. const updateInputHeight = function() {
  698. const inp = chatTextInput;
  699. inp.style.height = 'auto';
  700. const style = window.getComputedStyle(inp, null);
  701. const rect = inp.getBoundingClientRect();
  702. const padding = rect.height - parseInt(style.getPropertyValue('height'));
  703. inp.style.height = (inp.scrollHeight - padding) + 'px';
  704. };
  705. updateInputHeight();
  706. chatTextInput.addEventListener('input', updateInputHeight);
  707. /* Use a global buddy list handler via bubbling, rather than binding to each
  708. * buddy individually (slow) */
  709. const findParentWithClass = function(e, className) {
  710. const path = e.path || (e.composedPath && e.composedPath());
  711. if (path) {
  712. return path.find(l => l.classList && l.classList.contains(className));
  713. } else {
  714. /* Shim for older browsers */
  715. let target = e.target;
  716. while (target && !target.classList.contains(className)) {
  717. console.log(target);
  718. target = target.parentNode;
  719. }
  720. return target;
  721. }
  722. };
  723. const roomListSwitchHandler = function(e, className) {
  724. // Prevent "flashing" from the underlying anchor
  725. e.preventDefault();
  726. // Only switch if this was a primary-button click without any key modifiers
  727. if (e.button && e.button !== 0)
  728. return;
  729. if (e.ctrlKey || e.shiftKey || e.metaKey)
  730. return;
  731. const el = findParentWithClass(e, className);
  732. if (el) {
  733. switchPaneRoomID(el.dataset.id);
  734. }
  735. };
  736. const buddyListSwitchHandler = function(event) {
  737. roomListSwitchHandler(event, 'user');
  738. };
  739. const chatListSwitchHandler = function(event) {
  740. roomListSwitchHandler(event, 'chat');
  741. };
  742. buddyList.addEventListener('mousedown', buddyListSwitchHandler);
  743. chatList.addEventListener('mousedown', chatListSwitchHandler);
  744. /* It's not necessary to bind to click, since it's already in the JavaScript
  745. * href via the hash change */
  746. chatTextInput.addEventListener('input', () => {
  747. const hasText = chatTextInput.value.length > 0;
  748. if (!typingState && hasText) {
  749. /* We've started typing */
  750. sendTypingState(TYPING_TYPING);
  751. } else if (typingState && !hasText) {
  752. /* We've stopped typing */
  753. sendTypingState(TYPING_NONE);
  754. }
  755. });
  756. const submitChatMessage = () => {
  757. const content = chatTextInput.value;
  758. chatTextInput.value = '';
  759. updateInputHeight();
  760. /* Obviously if we've wiped the input, we're done typing */
  761. sendTypingState(TYPING_NONE);
  762. const emojied = window.emojify(content);
  763. if (content.length) {
  764. if (getRoomType(chatPaneRoomID) === 'buddy') {
  765. /* Feed through OTR */
  766. const otr = getBuddyOTR(chatPaneRoomID);
  767. otr.sendMsg(emojied);
  768. /* Mirror for ourselves, if it'll be OTR-encrypted (such that we
  769. * need the manual carbon) */
  770. if (otr.msgstate === window.OTR.CONST.MSGSTATE_ENCRYPTED) {
  771. onReceiveMessageDisplay({
  772. flags: PURPLE_MESSAGE_SEND,
  773. buddy: chatPaneRoomID,
  774. content: emojied
  775. }, true);
  776. }
  777. } else {
  778. /* mpOTR not supported */
  779. window.backendMessage(chatPaneRoomID, emojied);
  780. }
  781. }
  782. };
  783. chatForm.addEventListener('submit', event => {
  784. event.preventDefault();
  785. submitChatMessage();
  786. });
  787. const showBuddyPane = function() {
  788. app.classList.add('show-buddy-list');
  789. app.classList.remove('show-chat-area');
  790. };
  791. const hideBuddyPane = function() {
  792. app.classList.remove('show-buddy-list');
  793. app.classList.add('show-chat-area');
  794. };
  795. const openChatByHash = function() {
  796. const id = decodeURIComponent(location.hash.slice(1));
  797. /* No buddy ID means we meant the buddy list, rather than any particular
  798. * buddy */
  799. if (id === '') {
  800. showBuddyPane();
  801. return;
  802. }
  803. /* Otherwise, open the room requested */
  804. if (id in userDetailsDict || id in chatDetailsDict) {
  805. switchPaneRoomID(id);
  806. }
  807. };
  808. window.addEventListener('popstate', openChatByHash);
  809. roomBackButton.addEventListener('click', event => {
  810. const state = history.state || {};
  811. if (state.openedFromMobileIndex) {
  812. history.back();
  813. } else {
  814. location.hash = '#';
  815. }
  816. event.preventDefault();
  817. });
  818. toggleChatUserListButton.addEventListener('click', event => {
  819. app.classList.toggle('show-chat-user-list');
  820. event.preventDefault();
  821. });
  822. /* Login screen */
  823. const showLoginScreen = function() {
  824. loginScreen.classList.add('visible');
  825. };
  826. const hideLoginScreen = function() {
  827. loginScreen.classList.remove('visible');
  828. };
  829. const showLoginForm = function() {
  830. loginForm.classList.add('visible');
  831. if (loginUsernameInput.value) {
  832. loginPasswordInput.focus();
  833. } else {
  834. loginUsernameInput.focus();
  835. }
  836. };
  837. const hideLoginForm = function() {
  838. loginForm.classList.remove('visible');
  839. };
  840. const showLoginStatus = function(text) {
  841. if (loginStatus.firstChild) {
  842. loginStatus.removeChild(loginStatus.firstChild);
  843. }
  844. loginStatus.appendChild(document.createTextNode(text));
  845. loginStatus.classList.add('visible');
  846. };
  847. const hideLoginStatus = function() {
  848. loginStatus.classList.remove('visible');
  849. };
  850. const onReceiveAuthError = function() {
  851. gone = false;
  852. showLoginScreen();
  853. showLoginStatus('Failed login. Try again?');
  854. showLoginForm();
  855. // Login failed, clear password.
  856. if (hasLocalStorage)
  857. localStorage.passwordHash = '';
  858. };
  859. const onReceiveAuthSuccess = function() {
  860. hideLoginScreen();
  861. hideLoginStatus();
  862. };
  863. let in_ratelimit = false;
  864. const onReceiveRatelimit = function(data) {
  865. /* Slow everything down */
  866. in_ratelimit = true;
  867. setTimeout(() => {
  868. console.log('Clear!\n');
  869. /* Rate limit is over! */
  870. in_ratelimit = false;
  871. /* We're now clear to try again, reveal we were wrong */
  872. onReceiveAuthError();
  873. }, data.milliseconds);
  874. };
  875. const onWebSocketClosed = (was_connected) => {
  876. gone = false;
  877. /* If we're being ratelimited, don't do anything, since we already know we
  878. * got closed up on */
  879. if (in_ratelimit)
  880. return;
  881. if (was_connected) {
  882. showLoginScreen();
  883. showLoginStatus('Lost connection. Try again soon?');
  884. showLoginForm();
  885. } else {
  886. showLoginScreen();
  887. showLoginStatus('Could not connect. Try again soon?');
  888. showLoginForm();
  889. }
  890. };
  891. loginForm.addEventListener('submit', event => {
  892. event.preventDefault();
  893. const password = loginPasswordInput.value;
  894. const username = loginUsernameInput.value;
  895. /* Clear the password */
  896. loginPasswordInput.value = '';
  897. window.hashPassword(password).then(passwordHash => {
  898. // Save the username and password. If the login fails, we'll clear them.
  899. if (hasLocalStorage) {
  900. localStorage.username = username;
  901. localStorage.passwordHash = passwordHash;
  902. }
  903. go(username, passwordHash);
  904. });
  905. });
  906. /* Modals */
  907. const makeAccountDropdown = function() {
  908. let html = '';
  909. for (const userDetails of Object.values(selfUser)) {
  910. html += tmplAccountDropdownOption({userDetails});
  911. }
  912. return tmplAccountDropdown({options: html});
  913. };
  914. const showModal = function(contentHTML, action) {
  915. // Modals are for showing forms; contentHTML should contain a <form>, or
  916. // else a lot of the code here won't work.
  917. modalScreen.classList.add('visible');
  918. modalContent.innerHTML = contentHTML;
  919. const form = modalContent.querySelector('form');
  920. form.addEventListener('submit', event => {
  921. event.preventDefault();
  922. action(form);
  923. });
  924. form.elements[0].focus();
  925. const cancelButton = modalContent.querySelector('input[name=cancel]');
  926. if (cancelButton) {
  927. cancelButton.addEventListener('click', hideModal);
  928. }
  929. };
  930. const hideModal = () => {
  931. modalScreen.classList.remove('visible');
  932. };
  933. // Hide the modal when you click the modal screen..
  934. modalScreen.addEventListener('click', hideModal);
  935. // ..unless you clicked on the content.
  936. modalContent.addEventListener('click', event => event.stopPropagation());
  937. // Also hide the modal when you press escape.
  938. document.body.addEventListener('keydown', event => {
  939. if (event.which === KEY_ESCAPE) {
  940. hideModal();
  941. }
  942. });
  943. addBuddyButton.addEventListener('click', () => {
  944. const html = window.templates.modalAddBuddy({
  945. accountDropdown: makeAccountDropdown()
  946. });
  947. showModal(html, form => {
  948. const account = form.querySelector('[name=account]').value;
  949. const username = form.querySelector('[name=username]').value;
  950. window.backendRequestBuddy(account, username, username, 'Let\'s be sapphi(DEL)c(/DEL)re buddies! <3');
  951. hideModal();
  952. });
  953. });
  954. let changeAvatarModalAccount = null;
  955. const showChangeAvatar = function(accountDetails) {
  956. const html = window.templates.modalChangeAvatar({
  957. accountDetails
  958. });
  959. showModal(html, form => {
  960. const { files } = form.querySelector('[name=file]');
  961. const file = files[0];
  962. const reader = new FileReader();
  963. reader.onload = () => {
  964. const base64url = reader.result;
  965. // The base64 URL will start with a data: prefix. We want to send
  966. // just the data, so remove the prefix.
  967. const data = base64url.split(',')[1];
  968. // Set this so we can automatically close the modal once we receive
  969. // the changeAvatar event for this account. We set it only now that
  970. // we're actually uploading the new avatar. This is to deal with
  971. // the corner case of having this modal open while you change your
  972. // avatar externally (i.e. a separate client/instance/browser tab).
  973. // The modal on *this* instance - the one where you HAVEN'T yet
  974. // changed your avatar - won't close.
  975. changeAvatarModalAccount = accountDetails.id;
  976. window.backendChangeAvatar(accountDetails.id, data);
  977. };
  978. reader.onerror = error => {
  979. // TODO: Error handling here
  980. console.error(error);
  981. };
  982. reader.readAsDataURL(file);
  983. });
  984. };
  985. changeAvatarButton.addEventListener('click', () => {
  986. // TODO: Show a list of users.
  987. const accountDetails = Object.values(selfUser)[0];
  988. showChangeAvatar(accountDetails);
  989. });
  990. const onReceiveChangeAvatar = function(data) {
  991. // Update the account in userDetailsDict and/or selfUser, making note that
  992. // the user now has an avatar set if they didn't before.
  993. const account = data.id;
  994. if (userDetailsDict[account]) {
  995. // TODO: Rename this to "hasAvatar".
  996. userDetailsDict[account].hasIcon = true;
  997. processUser(userDetailsDict[account]);
  998. }
  999. if (selfUser[account]) {
  1000. selfUser[account].hasIcon = true;
  1001. processUser(selfUser[account]);
  1002. }
  1003. // Close the change avatar modal if this is the account that had its avatar
  1004. // changed.
  1005. if (account === changeAvatarModalAccount) {
  1006. hideModal();
  1007. changeAvatarModalAccount = null;
  1008. }
  1009. };
  1010. /* Initialize event handlers */
  1011. window.backendHooks.message = (d) => { onReceiveMessage(d, true); };
  1012. window.backendHooks.batchMessage = (d) => { onReceiveMessage(d, false); };
  1013. window.backendHooks.flushBatched = () => { flushMessageGroup(); scrollToBottom(true); };
  1014. window.backendHooks.typing = onTyping;
  1015. window.backendHooks.newBuddy = onReceiveUserDetails;
  1016. window.backendHooks.newChat = onReceiveChatDetails;
  1017. window.backendHooks.newAccount = onReceiveAccount;
  1018. window.backendHooks.changeAvatar = onReceiveChangeAvatar;
  1019. window.backendHooks.joined = onReceiveUserJoinedChat;
  1020. window.backendHooks.buddyStatus = onReceiveBuddyStatus;
  1021. window.backendHooks.topic = onReceiveChatTopic;
  1022. window.backendHooks.autherror = onReceiveAuthError;
  1023. window.backendHooks.authsuccess = onReceiveAuthSuccess;
  1024. window.backendHooks.ratelimit = onReceiveRatelimit;
  1025. window.backendHooks.wsclosed = onWebSocketClosed;
  1026. /* Fire away */
  1027. let gone = false;
  1028. const go = function(username, passwordHash) {
  1029. if (!gone) {
  1030. showLoginScreen();
  1031. showLoginStatus('Logging in...');
  1032. hideLoginForm();
  1033. window.backendConnect(username, passwordHash);
  1034. gone = true;
  1035. }
  1036. };
  1037. /* We need to check for localstorage since, although it exists in all of our
  1038. * target browsers, it may not be allowed per the content security settings
  1039. * (e.g. in Chromium); attempting to access it otherwise raises a security
  1040. * error. That said, a mere 'localStorage' in window check will not reveal this
  1041. * issue; only an attempt to access it. -AR */
  1042. let hasLocalStorage = false;
  1043. try {
  1044. localStorage;
  1045. hasLocalStorage = true;
  1046. } catch (e) {
  1047. console.warn('WARNING: No local storage...\n');
  1048. console.warn(e);
  1049. }
  1050. const storedPasswordHash = hasLocalStorage ? localStorage.passwordHash : null;
  1051. const storedUsername = hasLocalStorage ? localStorage.username : null;
  1052. if (storedUsername && storedPasswordHash) {
  1053. // TODO: Error handling. What if the password is wrong?
  1054. go(storedUsername, storedPasswordHash);
  1055. } else {
  1056. showLoginScreen();
  1057. showLoginForm();
  1058. }
  1059. //navigator.serviceWorker.register("src/service-worker.js");