diff --git a/.eslintrc.yml b/.eslintrc.yml index 4127a442..25ef87e0 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -9,54 +9,54 @@ env: node: true rules: - arrow-body-style: 2 - arrow-parens: [2, always] - arrow-spacing: 2 - block-scoped-var: 2 - block-spacing: [2, always] - brace-style: [2, 1tbs] - comma-dangle: 0 - curly: [2, all] - dot-location: [2, property] - dot-notation: 2 - eol-last: 2 - eqeqeq: 2 - handle-callback-err: 2 - indent: [2, tab] - key-spacing: [2, {beforeColon: false, afterColon: true}] - keyword-spacing: [2, {before: true, after: true}] - linebreak-style: [2, unix] - no-catch-shadow: 2 - no-confusing-arrow: 2 - no-console: 0 - no-control-regex: 0 - no-duplicate-imports: 2 - no-else-return: 2 - no-implicit-globals: 2 - no-multi-spaces: 2 - no-multiple-empty-lines: [2, { "max": 1 }] - no-shadow: 2 - no-template-curly-in-string: 2 - no-trailing-spaces: 2 - no-unsafe-negation: 2 - no-useless-computed-key: 2 - no-useless-return: 2 - object-curly-spacing: [2, never] - padded-blocks: [2, never] - prefer-const: 2 - quote-props: [2, as-needed] - quotes: [2, double, avoid-escape] - semi-spacing: 2 - semi-style: [2, last] - semi: [2, always] - space-before-blocks: 2 - space-before-function-paren: [2, never] - space-in-parens: [2, never] - space-infix-ops: 2 - spaced-comment: [2, always] - strict: 2 - template-curly-spacing: 2 - yoda: 2 + arrow-body-style: error + arrow-parens: [error, always] + arrow-spacing: error + block-scoped-var: error + block-spacing: [error, always] + brace-style: [error, 1tbs] + comma-dangle: off + curly: [error, all] + dot-location: [error, property] + dot-notation: error + eol-last: error + eqeqeq: error + handle-callback-err: error + indent: [error, tab] + key-spacing: [error, {beforeColon: false, afterColon: true}] + keyword-spacing: [error, {before: true, after: true}] + linebreak-style: [error, unix] + no-alert: error + no-catch-shadow: error + no-confusing-arrow: error + no-control-regex: off + no-duplicate-imports: error + no-else-return: error + no-implicit-globals: error + no-multi-spaces: error + no-multiple-empty-lines: [error, { "max": 1 }] + no-shadow: error + no-template-curly-in-string: error + no-trailing-spaces: error + no-unsafe-negation: error + no-useless-computed-key: error + no-useless-return: error + object-curly-spacing: [error, never] + padded-blocks: [error, never] + prefer-const: error + quote-props: [error, as-needed] + quotes: [error, double, avoid-escape] + semi-spacing: error + semi-style: [error, last] + semi: [error, always] + space-before-blocks: error + space-before-function-paren: [error, never] + space-in-parens: [error, never] + space-infix-ops: error + spaced-comment: [error, always] + strict: error + template-curly-spacing: error + yoda: error globals: log: false diff --git a/client/css/fonts/Lato-700/LICENSE.txt b/client/css/fonts/Lato-700/LICENSE.txt deleted file mode 100755 index 98383e3d..00000000 --- a/client/css/fonts/Lato-700/LICENSE.txt +++ /dev/null @@ -1,93 +0,0 @@ -Copyright (c) 2010-2014 by tyPoland Lukasz Dziedzic (team@latofonts.com) with Reserved Font Name "Lato" - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/client/css/fonts/Lato-700/Lato-700.woff b/client/css/fonts/Lato-700/Lato-700.woff deleted file mode 100755 index 66c8242c..00000000 Binary files a/client/css/fonts/Lato-700/Lato-700.woff and /dev/null differ diff --git a/client/css/fonts/Lato-700/Lato-700.woff2 b/client/css/fonts/Lato-700/Lato-700.woff2 deleted file mode 100755 index a9ffeae9..00000000 Binary files a/client/css/fonts/Lato-700/Lato-700.woff2 and /dev/null differ diff --git a/client/css/fonts/Lato-regular/LICENSE.txt b/client/css/fonts/Lato-regular/LICENSE.txt deleted file mode 100755 index 98383e3d..00000000 --- a/client/css/fonts/Lato-regular/LICENSE.txt +++ /dev/null @@ -1,93 +0,0 @@ -Copyright (c) 2010-2014 by tyPoland Lukasz Dziedzic (team@latofonts.com) with Reserved Font Name "Lato" - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/client/css/fonts/Lato-regular/Lato-regular.woff b/client/css/fonts/Lato-regular/Lato-regular.woff deleted file mode 100755 index fe27504d..00000000 Binary files a/client/css/fonts/Lato-regular/Lato-regular.woff and /dev/null differ diff --git a/client/css/fonts/Lato-regular/Lato-regular.woff2 b/client/css/fonts/Lato-regular/Lato-regular.woff2 deleted file mode 100755 index c83fe955..00000000 Binary files a/client/css/fonts/Lato-regular/Lato-regular.woff2 and /dev/null differ diff --git a/client/css/style.css b/client/css/style.css index 775aff25..63e45bc6 100644 --- a/client/css/style.css +++ b/client/css/style.css @@ -1,25 +1,3 @@ -@font-face { - font-family: "Lato"; - font-weight: 400; - font-style: normal; - src: - local("Lato Regular"), - local("Lato-regular"), - url("fonts/Lato-regular/Lato-regular.woff2") format("woff2"), - url("fonts/Lato-regular/Lato-regular.woff") format("woff"); -} - -@font-face { - font-family: "Lato"; - font-weight: 700; - font-style: normal; - src: - local("Lato Bold"), - local("Lato-700"), - url("fonts/Lato-700/Lato-700.woff2") format("woff2"), - url("fonts/Lato-700/Lato-700.woff") format("woff"); -} - @font-face { font-family: "FontAwesome"; font-weight: normal; @@ -37,13 +15,14 @@ body { body { background: #455164; color: #222; - font: 16px Lato, sans-serif; + font: 16px -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; margin: 0; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; cursor: default; + touch-action: none; /** * Disable pull-to-refresh on mobile that conflicts with scrolling the message list. @@ -159,6 +138,7 @@ kbd { .container { margin: 80px auto; max-width: 480px; + touch-action: pan-y; } ::-moz-placeholder { @@ -174,6 +154,7 @@ kbd { color: rgba(0, 0, 0, 0.35) !important; } +#js-copy-hack, #help, #windows .header .title, #windows .header .topic, @@ -185,6 +166,16 @@ kbd { cursor: text; } +#js-copy-hack { + position: absolute; + left: -999999px; +} + +#chat #js-copy-hack .condensed:not(.closed) .msg, +#chat #js-copy-hack > .msg { + display: block; +} + /* Icons */ #viewport .lt::before, @@ -197,6 +188,8 @@ kbd { #settings .extra-help, #settings #play::before, #form #submit::before, +#chat .away .from::before, +#chat .back .from::before, #chat .invite .from::before, #chat .join .from::before, #chat .kick .from::before, @@ -247,6 +240,12 @@ kbd { #form #submit::before { content: "\f1d8"; /* http://fontawesome.io/icon/paper-plane/ */ } +#chat .away .from::before, +#chat .back .from::before { + content: "\f017"; /* http://fontawesome.io/icon/clock-o/ */ + color: #7f8c8d; +} + #chat .invite .from::before { content: "\f003"; /* http://fontawesome.io/icon/envelope-o/ */ color: #2ecc40; @@ -473,6 +472,7 @@ kbd { #sidebar .networks { padding: 20px 30px 0; + touch-action: pan-y; } #sidebar .networks:empty { @@ -482,6 +482,7 @@ kbd { #sidebar .network, #sidebar .network-placeholder { margin-bottom: 30px; + touch-action: pan-y; } #sidebar .empty { @@ -834,7 +835,7 @@ kbd { display: flex; } -#chat .condensed-summary:hover { +#chat .condensed-summary .content:hover { opacity: 0.6; } @@ -850,13 +851,18 @@ kbd { visibility: hidden; } +#windows #form .input, #windows .header .topic, .messages .msg, .sidebar { - font: 13px Consolas, Menlo, Monaco, "Lucida Console", "DejaVu Sans Mono", "Courier New", monospace; + font-size: 14px; line-height: 1.4; } +#windows #form .input { + font-size: 13px; +} + #windows #chat .header { display: block; } @@ -896,6 +902,7 @@ kbd { right: 0; width: 180px; transition: right 0.4s; + touch-action: pan-y; } #chat .show-more { @@ -924,6 +931,7 @@ kbd { #chat .messages { padding: 10px 0; + touch-action: pan-y; } #chat .msg { @@ -1001,7 +1009,6 @@ kbd { #chat .time, #chat .from, #chat .content { - display: block; padding: 2px 0; flex: 0 0 auto; } @@ -1147,6 +1154,8 @@ kbd { } #chat .condensed .content, +#chat .away .content, +#chat .back .content, #chat .join .content, #chat .kick .content, #chat .mode .content, @@ -1286,6 +1295,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */ position: absolute; top: 48px; width: 100%; + touch-action: pan-y; } #chat .names-filtered { @@ -1463,6 +1473,15 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */ line-height: 1.8; } +.is-apple #help .key-all, +#help .key-apple { + display: none; +} + +.is-apple #help .key-apple { + display: inline-block; +} + #form { background: #eee; border-top: 1px solid #ddd; @@ -1472,7 +1491,6 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */ } #windows #form .input { - font: 13px Consolas, Menlo, Monaco, "Lucida Console", "DejaVu Sans Mono", "Courier New", monospace; border: 1px solid #ddd; border-radius: 2px; margin: 0; @@ -1485,6 +1503,15 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */ } #connection-error { + font-size: 12px; + line-height: 36px; + font-weight: bold; + letter-spacing: 1px; + word-spacing: 3px; + text-transform: uppercase; + background: #e74c3c; + color: #fff; + text-align: center; display: none; } @@ -1502,7 +1529,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */ background: #f6f6f6; color: #666; font: inherit; - font-size: 11px; + font-size: 13px; margin: 4px; line-height: 22px; height: 24px; @@ -1561,6 +1588,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */ -webkit-flex: 1 0 auto; flex: 1 0 auto; align-self: center; + touch-action: pan-y; } #form #submit { @@ -1671,7 +1699,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */ z-index: 1000000; display: none; padding: 5px 8px; - font: 12px Lato; + font-size: 12px; line-height: 1.2; -webkit-font-smoothing: subpixel-antialiased; color: #fff; @@ -1976,6 +2004,10 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */ margin-top: 60px !important; } + .messages .msg { + font-size: 16px; + } + #sidebar, #footer { left: -220px; @@ -2019,14 +2051,6 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */ } } -@media (min-width: 1610px) { - #windows .header .topic, - .messages .msg, - .sidebar { - font-size: 14px; - } -} - @media (max-width: 479px) { .container { margin: 40px 0 !important; @@ -2180,6 +2204,13 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */ /* Top/Bottom margins + button height + image/button margin */ max-height: calc(100vh - 2 * 10px - 37px - 10px); + + /* Checkered background for transparent images */ + background-position: 0 0, 10px 10px; + background-size: 20px 20px; + background-image: + linear-gradient(45deg, #eee 25%, rgba(0, 0, 0, 0) 25%, rgba(0, 0, 0, 0) 75%, #eee 75%, #eee 100%), + linear-gradient(45deg, #eee 25%, #fff 25%, #fff 75%, #eee 75%, #eee 100%); } #image-viewer .open-btn { diff --git a/client/index.html b/client/index.html index 44b8efed..5b170e63 100644 --- a/client/index.html +++ b/client/index.html @@ -63,7 +63,7 @@
- +
@@ -225,8 +225,8 @@

Status messages - - + +

@@ -264,8 +264,8 @@ @@ -392,11 +392,9 @@

Keyboard Shortcuts

-

On Windows / Linux

-
- Ctrl + / + Ctrl + /

Switch to the previous/next window in the channel list

@@ -405,16 +403,7 @@
- Ctrl + Shift + L -
-
-

Clear the current screen

-
-
- -
-
- Ctrl + K + Ctrl + K

@@ -431,7 +420,7 @@

- Ctrl + B + Ctrl + B

Mark all text typed after this shortcut as bold.

@@ -440,7 +429,7 @@
- Ctrl + U + Ctrl + U

Mark all text typed after this shortcut as underlined.

@@ -449,7 +438,7 @@
- Ctrl + I + Ctrl + I

Mark all text typed after this shortcut as italics.

@@ -458,83 +447,7 @@
- Ctrl + O -
-
-

- Mark all text typed after this shortcut to be reset to its - original formatting. -

-
-
- -

On macOS

- -
-
- + / -
-
-

Switch to the previous/next window in the channel list

-
-
- -
-
- + + L -
-
-

Clear the current screen

-
-
- -
-
- + K -
-
-

- Mark any text typed after this shortcut to be colored. After - hitting this shortcut, enter an integer in the - 0—15 range to select the desired color. -

-

- A color reference can be found - here. -

-
-
- -
-
- + B -
-
-

Mark all text typed after this shortcut as bold.

-
-
- -
-
- + U -
-
-

Mark all text typed after this shortcut as underlined.

-
-
- -
-
- + I -
-
-

Mark all text typed after this shortcut as italics.

-
-
- -
-
- + O + Ctrl + O

@@ -583,15 +496,6 @@

-
-
- /clear -
-
-

Clear the current screen.

-
-
-
/collapse @@ -787,10 +691,13 @@
- /part + /part [channel]
-

Close the current channel or private message window.

+

+ Close the specified channel or private message window, or the + current channel if channel is ommitted. +

Aliases: /close, /leave

diff --git a/client/js/clipboard.js b/client/js/clipboard.js new file mode 100644 index 00000000..8b8ba81b --- /dev/null +++ b/client/js/clipboard.js @@ -0,0 +1,38 @@ +"use strict"; + +const $ = require("jquery"); +const chat = document.getElementById("chat"); + +function copyMessages() { + const selection = window.getSelection(); + + // If selection does not span multiple elements, do nothing + if (selection.anchorNode === selection.focusNode) { + return; + } + + const range = selection.getRangeAt(0); + const documentFragment = range.cloneContents(); + const div = document.createElement("div"); + + $(documentFragment) + .find(".from .user") + .each((_, el) => { + el = $(el); + el.text(`<${el.text()}>`); + }); + + div.id = "js-copy-hack"; + div.appendChild(documentFragment); + chat.appendChild(div); + + selection.selectAllChildren(div); + + window.setTimeout(() => { + chat.removeChild(div); + selection.removeAllRanges(); + selection.addRange(range); + }, 0); +} + +$(chat).on("copy", ".messages", copyMessages); diff --git a/client/js/condensed.js b/client/js/condensed.js index aa1c8808..8f98fcda 100644 --- a/client/js/condensed.js +++ b/client/js/condensed.js @@ -23,6 +23,12 @@ function updateText(condensed, addedTypes) { constants.condensedTypes.forEach((type) => { if (obj[type]) { switch (type) { + case "away": + strings.push(obj[type] + (obj[type] > 1 ? " users have gone away" : " user has gone away")); + break; + case "back": + strings.push(obj[type] + (obj[type] > 1 ? " users have come back" : " user has come back")); + break; case "join": strings.push(obj[type] + (obj[type] > 1 ? " users have joined the channel" : " user has joined the channel")); break; diff --git a/client/js/constants.js b/client/js/constants.js index ec3745c4..3f408d0a 100644 --- a/client/js/constants.js +++ b/client/js/constants.js @@ -37,6 +37,7 @@ const commands = [ "/join", "/kick", "/leave", + "/list", "/me", "/mode", "/msg", @@ -59,6 +60,8 @@ const commands = [ ]; const actionTypes = [ + "away", + "back", "ban_list", "invite", "join", @@ -76,6 +79,8 @@ const actionTypes = [ ]; const condensedTypes = [ + "away", + "back", "join", "part", "quit", diff --git a/client/js/keybinds.js b/client/js/keybinds.js new file mode 100644 index 00000000..c98a73de --- /dev/null +++ b/client/js/keybinds.js @@ -0,0 +1,101 @@ +"use strict"; + +const $ = require("jquery"); +const Mousetrap = require("mousetrap"); +const input = $("#input"); +const sidebar = $("#sidebar"); +const windows = $("#windows"); +const contextMenuContainer = $("#context-menu-container"); + +Mousetrap.bind([ + "pageup", + "pagedown" +], function(e, key) { + let container = windows.find(".window.active"); + + // Chat windows scroll message container + if (container.attr("id") === "chat-container") { + container = container.find(".chan.active .chat"); + } + + container.finish(); + + const offset = container.get(0).clientHeight * 0.9; + let scrollTop = container.scrollTop(); + + if (key === "pageup") { + scrollTop = Math.floor(scrollTop - offset); + } else { + scrollTop = Math.ceil(scrollTop + offset); + } + + container.animate({ + scrollTop: scrollTop + }, 200); + + return false; +}); + +Mousetrap.bind([ + "command+up", + "command+down", + "ctrl+up", + "ctrl+down" +], function(e, keys) { + const channels = sidebar.find(".chan"); + const index = channels.index(channels.filter(".active")); + const direction = keys.split("+").pop(); + let target; + + switch (direction) { + case "up": + target = (channels.length + (index - 1 + channels.length)) % channels.length; + break; + + case "down": + target = (channels.length + (index + 1 + channels.length)) % channels.length; + break; + } + + channels.eq(target).click(); +}); + +Mousetrap.bind([ + "escape" +], function() { + contextMenuContainer.hide(); +}); + +const colorsHotkeys = { + k: "\x03", + b: "\x02", + u: "\x1F", + i: "\x1D", + o: "\x0F", +}; + +for (const hotkey in colorsHotkeys) { + Mousetrap.bind([ + "command+" + hotkey, + "ctrl+" + hotkey + ], function(e) { + e.preventDefault(); + + const cursorPosStart = input.prop("selectionStart"); + const cursorPosEnd = input.prop("selectionEnd"); + const value = input.val(); + let newValue = value.substring(0, cursorPosStart) + colorsHotkeys[e.key]; + + if (cursorPosStart === cursorPosEnd) { + // If no text is selected, insert at cursor + newValue += value.substring(cursorPosEnd, value.length); + } else { + // If text is selected, insert formatting character at start and the end + newValue += value.substring(cursorPosStart, cursorPosEnd) + colorsHotkeys[e.key] + value.substring(cursorPosEnd, value.length); + } + + input + .val(newValue) + .get(0).setSelectionRange(cursorPosStart + 1, cursorPosEnd + 1); + }); +} diff --git a/client/js/lounge.js b/client/js/lounge.js index d56aedd8..2ffa9f6d 100644 --- a/client/js/lounge.js +++ b/client/js/lounge.js @@ -4,24 +4,23 @@ require("jquery-ui/ui/widgets/sortable"); const $ = require("jquery"); const moment = require("moment"); -const Mousetrap = require("mousetrap"); const URI = require("urijs"); const fuzzy = require("fuzzy"); // our libraries require("./libs/jquery/inputhistory"); require("./libs/jquery/stickyscroll"); -const helpers_roundBadgeNumber = require("./libs/handlebars/roundBadgeNumber"); const slideoutMenu = require("./libs/slideout"); const templates = require("../views"); const socket = require("./socket"); require("./socket-events"); const storage = require("./localStorage"); -const options = require("./options"); +require("./options"); const utils = require("./utils"); -const modules = require("./modules"); require("./autocompletion"); require("./webpush"); +require("./keybinds"); +require("./clipboard"); $(function() { var sidebar = $("#sidebar, #footer"); @@ -29,20 +28,6 @@ $(function() { $(document.body).data("app-name", document.title); - var pop; - try { - pop = new Audio(); - pop.src = "audio/pop.ogg"; - } catch (e) { - pop = { - play: $.noop - }; - } - - $("#play").on("click", function() { - pop.play(); - }); - var windows = $("#windows"); var viewport = $("#viewport"); var sidebarSlide = slideoutMenu(viewport[0], sidebar[0]); @@ -183,6 +168,10 @@ $(function() { }); } + if (navigator.platform.match(/(Mac|iPhone|iPod|iPad)/i)) { + $(document.body).addClass("is-apple"); + } + $("#form").on("submit", function(e) { e.preventDefault(); utils.forceFocus(); @@ -199,22 +188,19 @@ $(function() { const separatorPos = text.indexOf(" "); const cmd = text.substring(1, separatorPos > 1 ? separatorPos : text.length); const parameters = separatorPos > text.indexOf(cmd) ? text.substring(text.indexOf(cmd) + cmd.length + 1, text.length) : ""; - switch (cmd) { - case "clear": - if (modules.clear()) return; - break; - case "collapse": - if (modules.collapse()) return; - break; - case "expand": - if (modules.expand()) return; - break; - case "join": + if (typeof utils[cmd] === "function") { + if (cmd === "join") { const channel = parameters.split(" ")[0]; - if (channel != "") { - if (modules.join(channel)) return; + if (channel !== "") { + if (utils[cmd](channel)) { + return; + } } - break; + } else { + if (utils[cmd]()) { + return; + } + } } } @@ -327,16 +313,16 @@ $(function() { const state = {}; if (self.hasClass("chan")) { - state.clickTarget = `.chan[data-id="${self.data("id")}"]`; + 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, null); + history.replaceState(state, null, target); } else { - history.pushState(state, null, null); + history.pushState(state, null, target); } } }); @@ -382,6 +368,7 @@ $(function() { lastActiveChan .find(".unread-marker") + .data("unread-id", 0) .appendTo(lastActiveChan.find(".messages")); var chan = $(target) @@ -433,7 +420,7 @@ $(function() { if (chan.hasClass("lobby")) { cmd = "/quit"; var server = chan.find(".name").html(); - if (!confirm("Disconnect from " + server + "?")) { + if (!confirm("Disconnect from " + server + "?")) { // eslint-disable-line no-alert return false; } } @@ -490,95 +477,6 @@ $(function() { container.html(templates.user_filtered({matches: result})).show(); }); - chat.on("msg", ".messages", function(e, target, msg) { - var unread = msg.unread; - msg = msg.msg; - - if (msg.self) { - return; - } - - var button = sidebar.find(".chan[data-target='" + target + "']"); - if (msg.highlight || (options.notifyAllMessages && msg.type === "message")) { - if (!document.hasFocus() || !$(target).hasClass("active")) { - if (options.notification) { - try { - pop.play(); - } catch (exception) { - // On mobile, sounds can not be played without user interaction. - } - } - utils.toggleNotificationMarkers(true); - - if (options.desktopNotifications && Notification.permission === "granted") { - var title; - var body; - - if (msg.type === "invite") { - title = "New channel invite:"; - body = msg.from + " invited you to " + msg.channel; - } else { - title = msg.from; - if (!button.hasClass("query")) { - title += " (" + button.data("title").trim() + ")"; - } - if (msg.type === "message") { - title += " says:"; - } - body = msg.text.replace(/\x03(?:[0-9]{1,2}(?:,[0-9]{1,2})?)?|[\x00-\x1F]|\x7F/g, "").trim(); - } - - try { - var notify = new Notification(title, { - body: body, - icon: "img/logo-64.png", - tag: target - }); - notify.addEventListener("click", function() { - window.focus(); - button.click(); - this.close(); - }); - } catch (exception) { - // `new Notification(...)` is not supported and should be silenced. - } - } - } - } - - if (button.hasClass("active")) { - return; - } - - if (!unread) { - return; - } - - var badge = button.find(".badge").html(helpers_roundBadgeNumber(unread)); - - if (msg.highlight) { - badge.addClass("highlight"); - } - }); - - chat.on("click", ".show-more-button", function() { - var self = $(this); - var lastMessage = self.parent().next(".messages").children(".msg").first(); - if (lastMessage.is(".condensed")) { - lastMessage = lastMessage.children(".msg").first(); - } - var lastMessageId = parseInt(lastMessage[0].id.replace("msg-", ""), 10); - - self - .text("Loading older messages…") - .prop("disabled", true); - - socket.emit("more", { - target: self.data("id"), - lastId: lastMessageId - }); - }); - var forms = $("#sign-in, #connect, #change-password"); windows.on("show", "#sign-in", function() { @@ -590,7 +488,8 @@ $(function() { } }); }); - if ($("body").hasClass("public")) { + + if ($("body").hasClass("public") && window.location.hash === "#connect") { $("#connect").one("show", function() { var params = URI(document.location.search); params = params.search(true); @@ -620,23 +519,25 @@ $(function() { e.preventDefault(); var event = "auth"; var form = $(this); - form.find(".btn") - .attr("disabled", true) - .end(); + form.find(".btn").attr("disabled", true); + if (form.closest(".window").attr("id") === "connect") { event = "conn"; } else if (form.closest("div").attr("id") === "change-password") { event = "change-password"; } + var values = {}; $.each(form.serializeArray(), function(i, obj) { if (obj.value !== "") { values[obj.name] = obj.value; } }); + if (values.user) { storage.set("user", values.user); } + socket.emit( event, values ); @@ -664,111 +565,6 @@ $(function() { $(this).data("lastvalue", nick); }); - (function HotkeysScope() { - Mousetrap.bind([ - "pageup", - "pagedown" - ], function(e, key) { - let container = windows.find(".window.active"); - - // Chat windows scroll message container - if (container.attr("id") === "chat-container") { - container = container.find(".chan.active .chat"); - } - - container.finish(); - - const offset = container.get(0).clientHeight * 0.9; - let scrollTop = container.scrollTop(); - - if (key === "pageup") { - scrollTop = Math.floor(scrollTop - offset); - } else { - scrollTop = Math.ceil(scrollTop + offset); - } - - container.animate({ - scrollTop: scrollTop - }, 200); - - return false; - }); - - Mousetrap.bind([ - "command+up", - "command+down", - "ctrl+up", - "ctrl+down" - ], function(e, keys) { - var channels = sidebar.find(".chan"); - var index = channels.index(channels.filter(".active")); - var direction = keys.split("+").pop(); - switch (direction) { - case "up": - // Loop - var upTarget = (channels.length + (index - 1 + channels.length)) % channels.length; - channels.eq(upTarget).click(); - break; - - case "down": - // Loop - var downTarget = (channels.length + (index + 1 + channels.length)) % channels.length; - channels.eq(downTarget).click(); - break; - } - }); - - Mousetrap.bind([ - "command+shift+l", - "ctrl+shift+l" - ], function(e) { - if (e.target === input[0]) { - utils.clear(); - e.preventDefault(); - } - }); - - Mousetrap.bind([ - "escape" - ], function() { - contextMenuContainer.hide(); - }); - - var colorsHotkeys = { - k: "\x03", - b: "\x02", - u: "\x1F", - i: "\x1D", - o: "\x0F", - }; - - for (var hotkey in colorsHotkeys) { - Mousetrap.bind([ - "command+" + hotkey, - "ctrl+" + hotkey - ], function(e) { - e.preventDefault(); - - const cursorPosStart = input.prop("selectionStart"); - const cursorPosEnd = input.prop("selectionEnd"); - const value = input.val(); - let newValue = value.substring(0, cursorPosStart) + colorsHotkeys[e.key]; - - if (cursorPosStart === cursorPosEnd) { - // If no text is selected, insert at cursor - newValue += value.substring(cursorPosEnd, value.length); - } else { - // If text is selected, insert formatting character at start and the end - newValue += value.substring(cursorPosStart, cursorPosEnd) + colorsHotkeys[e.key] + value.substring(cursorPosEnd, value.length); - } - - input - .val(newValue) - .get(0).setSelectionRange(cursorPosStart + 1, cursorPosEnd + 1); - }); - } - }()); - $(document).on("visibilitychange focus click", () => { if (sidebar.find(".highlight").length === 0) { utils.toggleNotificationMarkers(false); @@ -786,7 +582,7 @@ $(function() { $(".date-marker-text[data-label='Today'], .date-marker-text[data-label='Yesterday']") .closest(".date-marker-container") .each(function() { - $(this).replaceWith(templates.date_marker({msgDate: $(this).data("timestamp")})); + $(this).replaceWith(templates.date_marker({time: $(this).data("time")})); }); // This should always be 24h later but re-computing exact value just in case @@ -794,20 +590,33 @@ $(function() { } setTimeout(updateDateMarkers, msUntilNextDay()); - // Only start opening socket.io connection after all events have been registered - socket.open(); - window.addEventListener("popstate", (e) => { const {state} = e; if (!state) { return; } - const {clickTarget} = state; + let {clickTarget} = state; + if (clickTarget) { + // 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"); + + // 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(); }); diff --git a/client/js/modules.js b/client/js/modules.js deleted file mode 100644 index dab0a22c..00000000 --- a/client/js/modules.js +++ /dev/null @@ -1,35 +0,0 @@ -"use strict"; - -// vendor libraries -const $ = require("jquery"); - -// our libraries -const utils = require("./utils"); - -module.exports = { - clear, - collapse, - expand, - join -}; - -function clear() { - utils.clear(); -} - -function collapse() { - $(".chan.active .toggle-button.opened").click(); -} - -function expand() { - $(".chan.active .toggle-button:not(.opened)").click(); -} - -function join(channel) { - var chan = utils.findCurrentNetworkChan(channel); - - if (chan.length) { - chan.click(); - return true; - } -} diff --git a/client/js/render.js b/client/js/render.js index 748ef771..bb8be296 100644 --- a/client/js/render.js +++ b/client/js/render.js @@ -8,6 +8,7 @@ const utils = require("./utils"); const sorting = require("./sorting"); const constants = require("./constants"); const condensed = require("./condensed"); +const helpers_parse = require("./libs/handlebars/parse"); const chat = $("#chat"); const sidebar = $("#sidebar"); @@ -27,45 +28,29 @@ module.exports = { renderNetworks, }; -function buildChannelMessages(chanId, chanType, messages) { +function buildChannelMessages(container, chanId, chanType, messages) { return messages.reduce((docFragment, message) => { appendMessage(docFragment, chanId, chanType, message); return docFragment; - }, $(document.createDocumentFragment())); + }, container); } function appendMessage(container, chanId, chanType, msg) { - const renderedMessage = buildChatMessage(chanId, msg); + if (utils.lastMessageId < msg.id) { + utils.lastMessageId = msg.id; + } + + let lastChild = container.children(".msg, .date-marker-container").last(); + const renderedMessage = buildChatMessage(msg); // Check if date changed - let lastChild = container.find(".msg").last(); const msgTime = new Date(msg.time); - - // It's the first message in a window, - // then just append the message and do nothing else - if (lastChild.length === 0) { - container - .append(templates.date_marker({msgDate: msgTime})) - .append(renderedMessage); - - return; - } - - const prevMsgTime = new Date(lastChild.attr("data-time")); - const parent = lastChild.parent(); - - // If this message is condensed, we have to work on the wrapper - if (parent.hasClass("condensed")) { - lastChild = parent; - } + const prevMsgTime = new Date(lastChild.data("time")); // Insert date marker if date changed compared to previous message if (prevMsgTime.toDateString() !== msgTime.toDateString()) { - lastChild.after(templates.date_marker({msgDate: msgTime})); - - // If date changed, we don't need to do condensed logic - container.append(renderedMessage); - return; + lastChild = $(templates.date_marker({time: msg.time})); + container.append(lastChild); } // If current window is not a channel or this message is not condensable, @@ -83,25 +68,16 @@ function appendMessage(container, chanId, chanType, msg) { return; } - const newCondensed = buildChatMessage(chanId, { - type: "condensed", - time: msg.time, - previews: [] - }); + // Always create a condensed container + const newCondensed = $(templates.msg_condensed({time: msg.time})); condensed.updateText(newCondensed, [msg.type]); newCondensed.append(renderedMessage); container.append(newCondensed); } -function buildChatMessage(chanId, msg) { +function buildChatMessage(msg) { const type = msg.type; - let target = "#chan-" + chanId; - if (type === "error") { - target = "#chan-" + chat.find(".active").data("id"); - } - - const chan = chat.find(target); let template = "msg"; // See if any of the custom highlight regexes match @@ -117,8 +93,6 @@ function buildChatMessage(chanId, msg) { template = "msg_action"; } else if (type === "unhandled") { template = "msg_unhandled"; - } else if (type === "condensed") { - template = "msg_condensed"; } const renderedMessage = $(templates[template](msg)); @@ -132,17 +106,6 @@ function buildChatMessage(chanId, msg) { renderPreview(preview, renderedMessage); }); - if ((type === "message" || type === "action" || type === "notice") && chan.hasClass("channel")) { - const nicks = chan.find(".users").data("nicks"); - if (nicks) { - const find = nicks.indexOf(msg.from); - if (find !== -1) { - nicks.splice(find, 1); - nicks.unshift(msg.from); - } - } - } - return renderedMessage; } @@ -159,22 +122,28 @@ function renderChannel(data) { } function renderChannelMessages(data) { - const documentFragment = buildChannelMessages(data.id, data.type, data.messages); + const documentFragment = buildChannelMessages($(document.createDocumentFragment()), data.id, data.type, data.messages); const channel = chat.find("#chan-" + data.id + " .messages").append(documentFragment); - if (data.firstUnread > 0) { - const first = channel.find("#msg-" + data.firstUnread); + const template = $(templates.unread_marker()); + + if (data.firstUnread > 0) { + let first = channel.find("#msg-" + data.firstUnread); - // TODO: If the message is far off in the history, we still need to append the marker into DOM if (!first.length) { - channel.prepend(templates.unread_marker()); - } else if (first.parent().hasClass("condensed")) { - first.parent().before(templates.unread_marker()); + template.data("unread-id", data.firstUnread); + channel.prepend(template); } else { - first.before(templates.unread_marker()); + const parent = first.parent(); + + if (parent.hasClass("condensed")) { + first = parent; + } + + first.before(template); } } else { - channel.append(templates.unread_marker()); + channel.append(template); } } @@ -200,7 +169,7 @@ function renderChannelUsers(data) { } } -function renderNetworks(data) { +function renderNetworks(data, singleNetwork) { sidebar.find(".empty").hide(); sidebar.find(".networks").append( templates.network({ @@ -208,15 +177,51 @@ function renderNetworks(data) { }) ); + let newChannels; const channels = $.map(data.networks, function(n) { return n.channels; }); + + if (!singleNetwork && utils.lastMessageId > -1) { + newChannels = []; + + channels.forEach((channel) => { + const chan = $("#chan-" + channel.id); + + if (chan.length > 0) { + if (chan.data("type") === "channel") { + chan + .data("needsNamesRefresh", true) + .find(".header .topic") + .html(helpers_parse(channel.topic)) + .attr("title", channel.topic); + } + + if (channel.messages.length > 0) { + const container = chan.find(".messages"); + buildChannelMessages(container, channel.id, channel.type, channel.messages); + + if (container.find(".msg").length >= 100) { + container.find(".show-more").addClass("show"); + } + + container.trigger("keepToBottom"); + } + } else { + newChannels.push(channel); + } + }); + } else { + newChannels = channels; + } + chat.append( templates.chat({ channels: channels }) ); - channels.forEach((channel) => { + + newChannels.forEach((channel) => { renderChannel(channel); if (channel.type === "channel") { diff --git a/client/js/renderPreview.js b/client/js/renderPreview.js index 14edb400..ab73b070 100644 --- a/client/js/renderPreview.js +++ b/client/js/renderPreview.js @@ -92,10 +92,12 @@ function handleImageInPreview(content, container) { const imageViewer = $("#image-viewer"); -$("#chat").on("click", ".toggle-thumbnail", function() { +$("#chat").on("click", ".toggle-thumbnail", function(event, data = {}) { const link = $(this); - openImageViewer(link); + // Passing `data`, specifically `data.pushState`, to not add the action to the + // history state if back or forward buttons were pressed. + openImageViewer(link, data); // Prevent the link to open a new page since we're opening the image viewer, // but keep it a link to allow for Ctrl/Cmd+click. @@ -103,8 +105,10 @@ $("#chat").on("click", ".toggle-thumbnail", function() { return false; }); -imageViewer.on("click", function() { - closeImageViewer(); +imageViewer.on("click", function(event, data = {}) { + // Passing `data`, specifically `data.pushState`, to not add the action to the + // history state if back or forward buttons were pressed. + closeImageViewer(data); }); $(document).keydown(function(e) { @@ -125,7 +129,7 @@ $(document).keydown(function(e) { } }); -function openImageViewer(link) { +function openImageViewer(link, {pushState = true} = {}) { $(".previous-image").removeClass("previous-image"); $(".next-image").removeClass("next-image"); @@ -161,7 +165,20 @@ function openImageViewer(link) { hasNextImage: nextImage.length > 0, })); - imageViewer.addClass("opened"); + // Turn off transitionend listener before opening the viewer, + // which caused image viewer to become empty in rare cases + imageViewer + .off("transitionend") + .addClass("opened"); + + // History management + if (pushState) { + const clickTarget = + `#${link.closest(".msg").attr("id")} ` + + `a.toggle-thumbnail[href="${link.attr("href")}"] ` + + "img"; + history.pushState({clickTarget}, null, null); + } } imageViewer.on("click", ".previous-image-btn", function() { @@ -174,7 +191,7 @@ imageViewer.on("click", ".next-image-btn", function() { return false; }); -function closeImageViewer() { +function closeImageViewer({pushState = true} = {}) { imageViewer .removeClass("opened") .one("transitionend", function() { @@ -182,4 +199,12 @@ function closeImageViewer() { }); input.focus(); + + // History management + if (pushState) { + const clickTarget = + "#sidebar " + + `.chan[data-id="${$("#sidebar .chan.active").data("id")}"]`; + history.pushState({clickTarget}, null, null); + } } diff --git a/client/js/socket-events/auth.js b/client/js/socket-events/auth.js index ad3539da..e544948f 100644 --- a/client/js/socket-events/auth.js +++ b/client/js/socket-events/auth.js @@ -3,8 +3,20 @@ const $ = require("jquery"); const socket = require("../socket"); const storage = require("../localStorage"); +const utils = require("../utils"); socket.on("auth", function(data) { + // If we reconnected and serverHash differs, that means the server restarted + // And we will reload the page to grab the latest version + if (utils.serverHash > -1 && data.serverHash > -1 && data.serverHash !== utils.serverHash) { + socket.disconnect(); + $("#connection-error").text("Server restarted, reloading…"); + location.reload(true); + return; + } + + utils.serverHash = data.serverHash; + const login = $("#sign-in"); let token; const user = storage.get("user"); @@ -12,6 +24,13 @@ socket.on("auth", function(data) { login.find(".btn").prop("disabled", false); if (!data.success) { + if (login.length === 0) { + socket.disconnect(); + $("#connection-error").text("Authentication failed, reloading…"); + location.reload(); + return; + } + storage.remove("token"); const error = login.find(".error"); @@ -20,9 +39,15 @@ socket.on("auth", function(data) { }); } else if (user) { token = storage.get("token"); + if (token) { - $("#loading-page-message").text("Authorizing…"); - socket.emit("auth", {user: user, token: token}); + $("#loading-page-message, #connection-error").text("Authorizing…"); + + socket.emit("auth", { + user: user, + token: token, + lastMessage: utils.lastMessageId, + }); } } @@ -34,13 +59,9 @@ socket.on("auth", function(data) { return; } - $("#footer").find(".sign-in") + $("#footer") + .find(".sign-in") .trigger("click", { pushState: false, - }) - .end() - .find(".networks") - .html("") - .next() - .show(); + }); }); diff --git a/client/js/socket-events/init.js b/client/js/socket-events/init.js index a78c07fa..ec8d4988 100644 --- a/client/js/socket-events/init.js +++ b/client/js/socket-events/init.js @@ -1,16 +1,28 @@ "use strict"; const $ = require("jquery"); +const escape = require("css.escape"); const socket = require("../socket"); const render = require("../render"); const webpush = require("../webpush"); const sidebar = $("#sidebar"); const storage = require("../localStorage"); +const utils = require("../utils"); socket.on("init", function(data) { - $("#loading-page-message").text("Rendering…"); + $("#loading-page-message, #connection-error").text("Rendering…"); + + const lastMessageId = utils.lastMessageId; + let previousActive = 0; + + if (lastMessageId > -1) { + previousActive = sidebar.find(".active").data("id"); + sidebar.find(".networks").empty(); + } if (data.networks.length === 0) { + sidebar.find(".empty").show(); + $("#footer").find(".connect").trigger("click", { pushState: false, }); @@ -18,28 +30,59 @@ socket.on("init", function(data) { render.renderNetworks(data); } - if (data.token) { - storage.set("token", data.token); - } - - webpush.configurePushNotifications(data.pushSubscription, data.applicationServerKey); - - $("body").removeClass("signed-out"); - $("#loading").remove(); - $("#sign-in").remove(); - - const id = data.active; - const target = sidebar.find("[data-id='" + id + "']").trigger("click", { - replaceHistory: true - }); - if (target.length === 0) { - const first = sidebar.find(".chan") - .eq(0) - .trigger("click"); - if (first.length === 0) { - $("#footer").find(".connect").trigger("click", { - pushState: false, - }); + if (lastMessageId > -1) { + $("#connection-error").removeClass("shown"); + $(".show-more-button, #input").prop("disabled", false); + $("#submit").show(); + } else { + if (data.token) { + storage.set("token", data.token); } + + webpush.configurePushNotifications(data.pushSubscription, data.applicationServerKey); + + $("body").removeClass("signed-out"); + $("#loading").remove(); + $("#sign-in").remove(); } + + openCorrectChannel(previousActive, data.active); }); + +function openCorrectChannel(clientActive, serverActive) { + let target = $(); + + // Open last active channel + if (clientActive > 0) { + target = sidebar.find("[data-id='" + clientActive + "']"); + } + + // Open window provided in location.hash + if (target.length === 0 && window.location.hash) { + target = $("#footer, #sidebar").find("[data-target='" + escape(window.location.hash) + "']"); + } + + // Open last active channel according to the server + if (serverActive > 0 && target.length === 0) { + target = sidebar.find("[data-id='" + serverActive + "']"); + } + + // Open first available channel + if (target.length === 0) { + target = sidebar.find(".chan").first(); + } + + // If target channel is found, open it + if (target.length > 0) { + target.trigger("click", { + replaceHistory: true + }); + + return; + } + + // Open the connect window + $("#footer .connect").trigger("click", { + pushState: false + }); +} diff --git a/client/js/socket-events/more.js b/client/js/socket-events/more.js index 20d5c12d..ed4f15c4 100644 --- a/client/js/socket-events/more.js +++ b/client/js/socket-events/more.js @@ -33,8 +33,30 @@ socket.on("more", function(data) { } // Add the older messages - const documentFragment = render.buildChannelMessages(data.chan, type, data.messages); - chan.prepend(documentFragment).end(); + const documentFragment = render.buildChannelMessages($(document.createDocumentFragment()), data.chan, type, data.messages); + chan.prepend(documentFragment); + + // Move unread marker to correct spot if needed + const unreadMarker = chan.find(".unread-marker"); + const firstUnread = unreadMarker.data("unread-id"); + + if (firstUnread > 0) { + let first = chan.find("#msg-" + firstUnread); + + if (!first.length) { + chan.prepend(unreadMarker); + } else { + const parent = first.parent(); + + if (parent.hasClass("condensed")) { + first = parent; + } + + unreadMarker.data("unread-id", 0); + + first.before(unreadMarker); + } + } // restore scroll position const position = chan.height() - heightOld; @@ -54,3 +76,22 @@ socket.on("more", function(data) { .text("Show older messages") .prop("disabled", false); }); + +chat.on("click", ".show-more-button", function() { + const self = $(this); + const lastMessage = self.closest(".chat").find(".msg:not(.condensed)").first(); + let lastMessageId = -1; + + if (lastMessage.length > 0) { + lastMessageId = parseInt(lastMessage.attr("id").replace("msg-", ""), 10); + } + + self + .text("Loading older messages…") + .prop("disabled", true); + + socket.emit("more", { + target: self.data("id"), + lastId: lastMessageId + }); +}); diff --git a/client/js/socket-events/msg.js b/client/js/socket-events/msg.js index 6549c66c..a576451f 100644 --- a/client/js/socket-events/msg.js +++ b/client/js/socket-events/msg.js @@ -3,17 +3,27 @@ const $ = require("jquery"); const socket = require("../socket"); const render = require("../render"); +const utils = require("../utils"); +const options = require("../options"); +const helpers_roundBadgeNumber = require("../libs/handlebars/roundBadgeNumber"); const chat = $("#chat"); +const sidebar = $("#sidebar"); + +let pop; +try { + pop = new Audio(); + pop.src = "audio/pop.ogg"; +} catch (e) { + pop = { + play: $.noop + }; +} + +$("#play").on("click", () => pop.play()); socket.on("msg", function(data) { - if (window.requestIdleCallback) { - // During an idle period the user agent will run idle callbacks in FIFO order - // until either the idle period ends or there are no more idle callbacks eligible to be run. - // We set a maximum timeout of 2 seconds so that messages don't take too long to appear. - window.requestIdleCallback(() => processReceivedMessage(data), {timeout: 2000}); - } else { - processReceivedMessage(data); - } + // We set a maximum timeout of 2 seconds so that messages don't take too long to appear. + utils.requestIdleCallback(() => processReceivedMessage(data), 2000); }); function processReceivedMessage(data) { @@ -32,14 +42,13 @@ function processReceivedMessage(data) { render.appendMessage( container, targetId, - $(target).attr("data-type"), + channel.attr("data-type"), data.msg ); - container.trigger("msg", [ - target, - data - ]).trigger("keepToBottom"); + container.trigger("keepToBottom"); + + notifyMessage(targetId, channel, data); var lastVisible = container.find("div:visible").last(); if (data.msg.self @@ -48,6 +57,7 @@ function processReceivedMessage(data) { && lastVisible.prev().hasClass("unread-marker"))) { container .find(".unread-marker") + .data("unread-id", 0) .appendTo(container); } @@ -63,4 +73,83 @@ function processReceivedMessage(data) { } }); } + + if ((data.msg.type === "message" || data.msg.type === "action" || data.msg.type === "notice") && channel.hasClass("channel")) { + const nicks = channel.find(".users").data("nicks"); + if (nicks) { + const find = nicks.indexOf(data.msg.from); + if (find !== -1) { + nicks.splice(find, 1); + nicks.unshift(data.msg.from); + } + } + } +} + +function notifyMessage(targetId, channel, msg) { + const unread = msg.unread; + msg = msg.msg; + + if (msg.self) { + return; + } + + const button = sidebar.find(".chan[data-id='" + targetId + "']"); + if (msg.highlight || (options.notifyAllMessages && msg.type === "message")) { + if (!document.hasFocus() || !channel.hasClass("active")) { + if (options.notification) { + try { + pop.play(); + } catch (exception) { + // On mobile, sounds can not be played without user interaction. + } + } + + utils.toggleNotificationMarkers(true); + + if (options.desktopNotifications && Notification.permission === "granted") { + let title; + let body; + + if (msg.type === "invite") { + title = "New channel invite:"; + body = msg.from + " invited you to " + msg.channel; + } else { + title = msg.from; + if (!button.hasClass("query")) { + title += " (" + button.data("title").trim() + ")"; + } + if (msg.type === "message") { + title += " says:"; + } + body = msg.text.replace(/\x03(?:[0-9]{1,2}(?:,[0-9]{1,2})?)?|[\x00-\x1F]|\x7F/g, "").trim(); + } + + try { + const notify = new Notification(title, { + body: body, + icon: "img/logo-64.png", + tag: `lounge-${targetId}` + }); + notify.addEventListener("click", function() { + window.focus(); + button.click(); + this.close(); + }); + } catch (exception) { + // `new Notification(...)` is not supported and should be silenced. + } + } + } + } + + if (!unread || button.hasClass("active")) { + return; + } + + const badge = button.find(".badge").html(helpers_roundBadgeNumber(unread)); + + if (msg.highlight) { + badge.addClass("highlight"); + } } diff --git a/client/js/socket-events/msg_preview.js b/client/js/socket-events/msg_preview.js index 42972d61..426bcf9b 100644 --- a/client/js/socket-events/msg_preview.js +++ b/client/js/socket-events/msg_preview.js @@ -3,9 +3,9 @@ const $ = require("jquery"); const renderPreview = require("../renderPreview"); const socket = require("../socket"); +const utils = require("../utils"); socket.on("msg:preview", function(data) { - const msg = $("#msg-" + data.id); - - renderPreview(data.preview, msg); + // Previews are not as important, we can wait longer for them to appear + utils.requestIdleCallback(() => renderPreview(data.preview, $("#msg-" + data.id)), 6000); }); diff --git a/client/js/socket-events/network.js b/client/js/socket-events/network.js index 1fb8036f..a55b0433 100644 --- a/client/js/socket-events/network.js +++ b/client/js/socket-events/network.js @@ -6,7 +6,7 @@ const render = require("../render"); const sidebar = $("#sidebar"); socket.on("network", function(data) { - render.renderNetworks(data); + render.renderNetworks(data, true); sidebar.find(".chan") .last() @@ -14,11 +14,9 @@ socket.on("network", function(data) { $("#connect") .find(".btn") - .prop("disabled", false) - .end(); + .prop("disabled", false); }); socket.on("network_changed", function(data) { sidebar.find("#network-" + data.network).data("options", data.serverOptions); }); - diff --git a/client/js/socket-events/quit.js b/client/js/socket-events/quit.js index dcf1b8bd..8caa26bc 100644 --- a/client/js/socket-events/quit.js +++ b/client/js/socket-events/quit.js @@ -6,12 +6,12 @@ const sidebar = $("#sidebar"); socket.on("quit", function(data) { const id = data.network; - sidebar.find("#network-" + id) - .remove() - .end(); + sidebar.find("#network-" + id).remove(); + const chan = sidebar.find(".chan") .eq(0) .trigger("click"); + if (chan.length === 0) { sidebar.find(".empty").show(); } diff --git a/client/js/socket.js b/client/js/socket.js index 6f702fb1..a9916b0a 100644 --- a/client/js/socket.js +++ b/client/js/socket.js @@ -2,54 +2,54 @@ const $ = require("jquery"); const io = require("socket.io-client"); +const utils = require("./utils"); const path = window.location.pathname + "socket.io/"; +const status = $("#loading-page-message, #connection-error"); const socket = io({ transports: $(document.body).data("transports"), path: path, autoConnect: false, - reconnection: false + reconnection: !$(document.body).hasClass("public") }); -[ - "connect_error", - "connect_failed", - "disconnect", - "error", -].forEach(function(e) { - socket.on(e, function(data) { - $("#loading-page-message").text("Connection failed: " + data); - $("#connection-error").addClass("shown").one("click", function() { - window.onbeforeunload = null; - window.location.reload(); - }); +socket.on("disconnect", handleDisconnect); +socket.on("connect_error", handleDisconnect); +socket.on("error", handleDisconnect); - // Disables sending a message by pressing Enter. `off` is necessary to - // cancel `inputhistory`, which overrides hitting Enter. `on` is then - // necessary to avoid creating new lines when hitting Enter without Shift. - // This is fairly hacky but this solution is not permanent. - $("#input").off("keydown").on("keydown", function(event) { - if (event.which === 13 && !event.shiftKey) { - event.preventDefault(); - } - }); - // Hides the "Send Message" button - $("#submit").remove(); - - console.error(data); - }); +socket.on("reconnecting", function(attempt) { + status.text(`Reconnecting… (attempt ${attempt})`); }); socket.on("connecting", function() { - $("#loading-page-message").text("Connecting…"); + status.text("Connecting…"); }); socket.on("connect", function() { - $("#loading-page-message").text("Finalizing connection…"); + // Clear send buffer when reconnecting, socket.io would emit these + // immediately upon connection and it will have no effect, so we ensure + // nothing is sent to the server that might have happened. + socket.sendBuffer = []; + + status.text("Finalizing connection…"); }); socket.on("authorized", function() { - $("#loading-page-message").text("Authorized, loading messages…"); + status.text("Loading messages…"); }); +function handleDisconnect(data) { + const message = data.message || data; + + status.text(`Waiting to reconnect… (${message})`).addClass("shown"); + $(".show-more-button, #input").prop("disabled", true); + $("#submit").hide(); + + // If the server shuts down, socket.io skips reconnection + // and we have to manually call connect to start the process + if (socket.io.skipReconnect) { + utils.requestIdleCallback(() => socket.connect(), 2000); + } +} + module.exports = socket; diff --git a/client/js/utils.js b/client/js/utils.js index dc0bd7fa..2eae9a29 100644 --- a/client/js/utils.js +++ b/client/js/utils.js @@ -4,16 +4,25 @@ const $ = require("jquery"); const chat = $("#chat"); const input = $("#input"); +var serverHash = -1; +var lastMessageId = -1; + module.exports = { findCurrentNetworkChan, clear, + collapse, + expand, + join, + serverHash, + lastMessageId, confirmExit, forceFocus, move, resetHeight, setNick, toggleNickEditor, - toggleNotificationMarkers + toggleNotificationMarkers, + requestIdleCallback, }; function findCurrentNetworkChan(name) { @@ -42,6 +51,26 @@ function clear() { chat.find(".active") .find(".show-more").addClass("show").end() .find(".messages .msg, .date-marker-container").remove(); + return true; +} + +function collapse() { + $(".chan.active .toggle-button.opened").click(); + return true; +} + +function expand() { + $(".chan.active .toggle-button:not(.opened)").click(); + return true; +} + +function join(channel) { + var chan = findCurrentNetworkChan(channel); + + if (chan.length) { + chan.click(); + return true; + } } function toggleNickEditor(toggle) { @@ -90,3 +119,13 @@ function move(array, old_index, new_index) { array.splice(new_index, 0, array.splice(old_index, 1)[0]); return array; } + +function requestIdleCallback(callback, timeout) { + if (window.requestIdleCallback) { + // During an idle period the user agent will run idle callbacks in FIFO order + // until either the idle period ends or there are no more idle callbacks eligible to be run. + window.requestIdleCallback(callback, {timeout: timeout}); + } else { + callback(); + } +} diff --git a/client/service-worker.js b/client/service-worker.js index fcb63300..641e050b 100644 --- a/client/service-worker.js +++ b/client/service-worker.js @@ -9,16 +9,30 @@ self.addEventListener("push", function(event) { const payload = event.data.json(); - if (payload.type === "notification") { - event.waitUntil( - self.registration.showNotification(payload.title, { - badge: "img/logo-64.png", - icon: "img/touch-icon-192x192.png", - body: payload.body, - timestamp: payload.timestamp, - }) - ); + if (payload.type !== "notification") { + return; } + + // get current notification, close it, and draw new + event.waitUntil( + self.registration + .getNotifications({ + tag: `chan-${payload.chanId}` + }) + .then((notifications) => { + for (const notification of notifications) { + notification.close(); + } + + return self.registration.showNotification(payload.title, { + tag: `chan-${payload.chanId}`, + badge: "img/logo-64.png", + icon: "img/touch-icon-192x192.png", + body: payload.body, + timestamp: payload.timestamp, + }); + }) + ); }); self.addEventListener("notificationclick", function(event) { diff --git a/client/themes/crypto.css b/client/themes/crypto.css index 9b7bf72b..2f9f5424 100644 --- a/client/themes/crypto.css +++ b/client/themes/crypto.css @@ -65,12 +65,8 @@ a:hover, background: #00ff0e; } -.btn-reconnect { +#connection-error { background: #f00; - color: #fff; - border: 0; - border-radius: 0; - margin: 0; } #settings .opt { diff --git a/client/themes/example.css b/client/themes/example.css index a8efcbf9..d9764ac1 100644 --- a/client/themes/example.css +++ b/client/themes/example.css @@ -46,14 +46,6 @@ body { border-radius: 2px; } -.btn-reconnect { - background: #e74c3c; - color: #fff; - border: 0; - border-radius: 0; - margin: 0; -} - @media (max-width: 768px) { #sidebar { left: -220px; diff --git a/client/themes/morning.css b/client/themes/morning.css index 9067c7f9..0b576e8d 100644 --- a/client/themes/morning.css +++ b/client/themes/morning.css @@ -29,14 +29,6 @@ body { background: #333c4a; } -#windows .header .topic, -#windows #form .input, -.messages .msg, -.sidebar { - font-family: inherit; - font-size: 13px; -} - #chat .count { background-color: #2e3642; } @@ -213,14 +205,6 @@ body { color: #99a2b4; } -.btn-reconnect { - background: #e74c3c; - color: #fff; - border: 0; - border-radius: 0; - margin: 0; -} - /* Form elements */ #chat-container ::-moz-placeholder { diff --git a/client/themes/zenburn.css b/client/themes/zenburn.css index 525110c7..e4823cdf 100644 --- a/client/themes/zenburn.css +++ b/client/themes/zenburn.css @@ -30,14 +30,6 @@ body { background: #3f3f3f; } -#windows .header .topic, -#windows #form .input, -.messages .msg, -.sidebar { - font-family: inherit; - font-size: 13px; -} - #settings, #sign-in, #connect .title { @@ -240,14 +232,6 @@ body { color: #d2d39b; } -.btn-reconnect { - background: #e74c3c; - color: #fff; - border: 0; - border-radius: 0; - margin: 0; -} - /* Form elements */ #chat-container ::-moz-placeholder { diff --git a/client/views/actions/away.tpl b/client/views/actions/away.tpl new file mode 100644 index 00000000..f4e52519 --- /dev/null +++ b/client/views/actions/away.tpl @@ -0,0 +1,3 @@ +{{> ../user_name nick=from}} +is away +({{{parse text}}}) diff --git a/client/views/actions/back.tpl b/client/views/actions/back.tpl new file mode 100644 index 00000000..cb24ea5e --- /dev/null +++ b/client/views/actions/back.tpl @@ -0,0 +1,2 @@ +{{> ../user_name nick=from}} +is back diff --git a/client/views/actions/kick.tpl b/client/views/actions/kick.tpl index 425a62b8..d739f4c9 100644 --- a/client/views/actions/kick.tpl +++ b/client/views/actions/kick.tpl @@ -1,6 +1,6 @@ -{{> ../user_name nick=from}} +{{> ../user_name nick=from.nick mode=from.mode}} has kicked -{{> ../user_name nick=target mode=""}} +{{> ../user_name nick=target.nick mode=target.mode}} {{#if text}} ({{{parse text}}}) {{/if}} diff --git a/client/views/date-marker.tpl b/client/views/date-marker.tpl index 9e67f09f..b1d20be7 100644 --- a/client/views/date-marker.tpl +++ b/client/views/date-marker.tpl @@ -1,5 +1,5 @@ -
+
- +
diff --git a/client/views/index.js b/client/views/index.js index aae3c78b..50f6a93f 100644 --- a/client/views/index.js +++ b/client/views/index.js @@ -3,6 +3,8 @@ module.exports = { actions: { action: require("./actions/action.tpl"), + away: require("./actions/away.tpl"), + back: require("./actions/back.tpl"), ban_list: require("./actions/ban_list.tpl"), channel_list: require("./actions/channel_list.tpl"), ctcp: require("./actions/ctcp.tpl"), diff --git a/client/views/msg_preview_toggle.tpl b/client/views/msg_preview_toggle.tpl index 386282cc..c9e48f6e 100644 --- a/client/views/msg_preview_toggle.tpl +++ b/client/views/msg_preview_toggle.tpl @@ -1,5 +1,5 @@ {{#preview}} -