Simplify initial commit and remove unnecessary refactor
This commit is contained in:
commit
f1c40aa8de
@ -9,54 +9,54 @@ env:
|
|||||||
node: true
|
node: true
|
||||||
|
|
||||||
rules:
|
rules:
|
||||||
arrow-body-style: 2
|
arrow-body-style: error
|
||||||
arrow-parens: [2, always]
|
arrow-parens: [error, always]
|
||||||
arrow-spacing: 2
|
arrow-spacing: error
|
||||||
block-scoped-var: 2
|
block-scoped-var: error
|
||||||
block-spacing: [2, always]
|
block-spacing: [error, always]
|
||||||
brace-style: [2, 1tbs]
|
brace-style: [error, 1tbs]
|
||||||
comma-dangle: 0
|
comma-dangle: off
|
||||||
curly: [2, all]
|
curly: [error, all]
|
||||||
dot-location: [2, property]
|
dot-location: [error, property]
|
||||||
dot-notation: 2
|
dot-notation: error
|
||||||
eol-last: 2
|
eol-last: error
|
||||||
eqeqeq: 2
|
eqeqeq: error
|
||||||
handle-callback-err: 2
|
handle-callback-err: error
|
||||||
indent: [2, tab]
|
indent: [error, tab]
|
||||||
key-spacing: [2, {beforeColon: false, afterColon: true}]
|
key-spacing: [error, {beforeColon: false, afterColon: true}]
|
||||||
keyword-spacing: [2, {before: true, after: true}]
|
keyword-spacing: [error, {before: true, after: true}]
|
||||||
linebreak-style: [2, unix]
|
linebreak-style: [error, unix]
|
||||||
no-catch-shadow: 2
|
no-alert: error
|
||||||
no-confusing-arrow: 2
|
no-catch-shadow: error
|
||||||
no-console: 0
|
no-confusing-arrow: error
|
||||||
no-control-regex: 0
|
no-control-regex: off
|
||||||
no-duplicate-imports: 2
|
no-duplicate-imports: error
|
||||||
no-else-return: 2
|
no-else-return: error
|
||||||
no-implicit-globals: 2
|
no-implicit-globals: error
|
||||||
no-multi-spaces: 2
|
no-multi-spaces: error
|
||||||
no-multiple-empty-lines: [2, { "max": 1 }]
|
no-multiple-empty-lines: [error, { "max": 1 }]
|
||||||
no-shadow: 2
|
no-shadow: error
|
||||||
no-template-curly-in-string: 2
|
no-template-curly-in-string: error
|
||||||
no-trailing-spaces: 2
|
no-trailing-spaces: error
|
||||||
no-unsafe-negation: 2
|
no-unsafe-negation: error
|
||||||
no-useless-computed-key: 2
|
no-useless-computed-key: error
|
||||||
no-useless-return: 2
|
no-useless-return: error
|
||||||
object-curly-spacing: [2, never]
|
object-curly-spacing: [error, never]
|
||||||
padded-blocks: [2, never]
|
padded-blocks: [error, never]
|
||||||
prefer-const: 2
|
prefer-const: error
|
||||||
quote-props: [2, as-needed]
|
quote-props: [error, as-needed]
|
||||||
quotes: [2, double, avoid-escape]
|
quotes: [error, double, avoid-escape]
|
||||||
semi-spacing: 2
|
semi-spacing: error
|
||||||
semi-style: [2, last]
|
semi-style: [error, last]
|
||||||
semi: [2, always]
|
semi: [error, always]
|
||||||
space-before-blocks: 2
|
space-before-blocks: error
|
||||||
space-before-function-paren: [2, never]
|
space-before-function-paren: [error, never]
|
||||||
space-in-parens: [2, never]
|
space-in-parens: [error, never]
|
||||||
space-infix-ops: 2
|
space-infix-ops: error
|
||||||
spaced-comment: [2, always]
|
spaced-comment: [error, always]
|
||||||
strict: 2
|
strict: error
|
||||||
template-curly-spacing: 2
|
template-curly-spacing: error
|
||||||
yoda: 2
|
yoda: error
|
||||||
|
|
||||||
globals:
|
globals:
|
||||||
log: false
|
log: false
|
||||||
|
@ -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.
|
|
Binary file not shown.
Binary file not shown.
@ -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.
|
|
Binary file not shown.
Binary file not shown.
@ -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-face {
|
||||||
font-family: "FontAwesome";
|
font-family: "FontAwesome";
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
@ -37,13 +15,14 @@ body {
|
|||||||
body {
|
body {
|
||||||
background: #455164;
|
background: #455164;
|
||||||
color: #222;
|
color: #222;
|
||||||
font: 16px Lato, sans-serif;
|
font: 16px -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
-moz-user-select: none;
|
-moz-user-select: none;
|
||||||
-ms-user-select: none;
|
-ms-user-select: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
touch-action: none;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Disable pull-to-refresh on mobile that conflicts with scrolling the message list.
|
* Disable pull-to-refresh on mobile that conflicts with scrolling the message list.
|
||||||
@ -159,6 +138,7 @@ kbd {
|
|||||||
.container {
|
.container {
|
||||||
margin: 80px auto;
|
margin: 80px auto;
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
|
touch-action: pan-y;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-moz-placeholder {
|
::-moz-placeholder {
|
||||||
@ -174,6 +154,7 @@ kbd {
|
|||||||
color: rgba(0, 0, 0, 0.35) !important;
|
color: rgba(0, 0, 0, 0.35) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#js-copy-hack,
|
||||||
#help,
|
#help,
|
||||||
#windows .header .title,
|
#windows .header .title,
|
||||||
#windows .header .topic,
|
#windows .header .topic,
|
||||||
@ -185,6 +166,16 @@ kbd {
|
|||||||
cursor: text;
|
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 */
|
/* Icons */
|
||||||
|
|
||||||
#viewport .lt::before,
|
#viewport .lt::before,
|
||||||
@ -197,6 +188,8 @@ kbd {
|
|||||||
#settings .extra-help,
|
#settings .extra-help,
|
||||||
#settings #play::before,
|
#settings #play::before,
|
||||||
#form #submit::before,
|
#form #submit::before,
|
||||||
|
#chat .away .from::before,
|
||||||
|
#chat .back .from::before,
|
||||||
#chat .invite .from::before,
|
#chat .invite .from::before,
|
||||||
#chat .join .from::before,
|
#chat .join .from::before,
|
||||||
#chat .kick .from::before,
|
#chat .kick .from::before,
|
||||||
@ -247,6 +240,12 @@ kbd {
|
|||||||
|
|
||||||
#form #submit::before { content: "\f1d8"; /* http://fontawesome.io/icon/paper-plane/ */ }
|
#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 {
|
#chat .invite .from::before {
|
||||||
content: "\f003"; /* http://fontawesome.io/icon/envelope-o/ */
|
content: "\f003"; /* http://fontawesome.io/icon/envelope-o/ */
|
||||||
color: #2ecc40;
|
color: #2ecc40;
|
||||||
@ -473,6 +472,7 @@ kbd {
|
|||||||
|
|
||||||
#sidebar .networks {
|
#sidebar .networks {
|
||||||
padding: 20px 30px 0;
|
padding: 20px 30px 0;
|
||||||
|
touch-action: pan-y;
|
||||||
}
|
}
|
||||||
|
|
||||||
#sidebar .networks:empty {
|
#sidebar .networks:empty {
|
||||||
@ -482,6 +482,7 @@ kbd {
|
|||||||
#sidebar .network,
|
#sidebar .network,
|
||||||
#sidebar .network-placeholder {
|
#sidebar .network-placeholder {
|
||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
|
touch-action: pan-y;
|
||||||
}
|
}
|
||||||
|
|
||||||
#sidebar .empty {
|
#sidebar .empty {
|
||||||
@ -834,7 +835,7 @@ kbd {
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
#chat .condensed-summary:hover {
|
#chat .condensed-summary .content:hover {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -850,13 +851,18 @@ kbd {
|
|||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#windows #form .input,
|
||||||
#windows .header .topic,
|
#windows .header .topic,
|
||||||
.messages .msg,
|
.messages .msg,
|
||||||
.sidebar {
|
.sidebar {
|
||||||
font: 13px Consolas, Menlo, Monaco, "Lucida Console", "DejaVu Sans Mono", "Courier New", monospace;
|
font-size: 14px;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#windows #form .input {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
#windows #chat .header {
|
#windows #chat .header {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
@ -896,6 +902,7 @@ kbd {
|
|||||||
right: 0;
|
right: 0;
|
||||||
width: 180px;
|
width: 180px;
|
||||||
transition: right 0.4s;
|
transition: right 0.4s;
|
||||||
|
touch-action: pan-y;
|
||||||
}
|
}
|
||||||
|
|
||||||
#chat .show-more {
|
#chat .show-more {
|
||||||
@ -924,6 +931,7 @@ kbd {
|
|||||||
|
|
||||||
#chat .messages {
|
#chat .messages {
|
||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
|
touch-action: pan-y;
|
||||||
}
|
}
|
||||||
|
|
||||||
#chat .msg {
|
#chat .msg {
|
||||||
@ -1001,7 +1009,6 @@ kbd {
|
|||||||
#chat .time,
|
#chat .time,
|
||||||
#chat .from,
|
#chat .from,
|
||||||
#chat .content {
|
#chat .content {
|
||||||
display: block;
|
|
||||||
padding: 2px 0;
|
padding: 2px 0;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
@ -1147,6 +1154,8 @@ kbd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#chat .condensed .content,
|
#chat .condensed .content,
|
||||||
|
#chat .away .content,
|
||||||
|
#chat .back .content,
|
||||||
#chat .join .content,
|
#chat .join .content,
|
||||||
#chat .kick .content,
|
#chat .kick .content,
|
||||||
#chat .mode .content,
|
#chat .mode .content,
|
||||||
@ -1286,6 +1295,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 48px;
|
top: 48px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
touch-action: pan-y;
|
||||||
}
|
}
|
||||||
|
|
||||||
#chat .names-filtered {
|
#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;
|
line-height: 1.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.is-apple #help .key-all,
|
||||||
|
#help .key-apple {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-apple #help .key-apple {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
#form {
|
#form {
|
||||||
background: #eee;
|
background: #eee;
|
||||||
border-top: 1px solid #ddd;
|
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 {
|
#windows #form .input {
|
||||||
font: 13px Consolas, Menlo, Monaco, "Lucida Console", "DejaVu Sans Mono", "Courier New", monospace;
|
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -1485,6 +1503,15 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
|||||||
}
|
}
|
||||||
|
|
||||||
#connection-error {
|
#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;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1502,7 +1529,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
|||||||
background: #f6f6f6;
|
background: #f6f6f6;
|
||||||
color: #666;
|
color: #666;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 11px;
|
font-size: 13px;
|
||||||
margin: 4px;
|
margin: 4px;
|
||||||
line-height: 22px;
|
line-height: 22px;
|
||||||
height: 24px;
|
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;
|
-webkit-flex: 1 0 auto;
|
||||||
flex: 1 0 auto;
|
flex: 1 0 auto;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
|
touch-action: pan-y;
|
||||||
}
|
}
|
||||||
|
|
||||||
#form #submit {
|
#form #submit {
|
||||||
@ -1671,7 +1699,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
|||||||
z-index: 1000000;
|
z-index: 1000000;
|
||||||
display: none;
|
display: none;
|
||||||
padding: 5px 8px;
|
padding: 5px 8px;
|
||||||
font: 12px Lato;
|
font-size: 12px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
-webkit-font-smoothing: subpixel-antialiased;
|
-webkit-font-smoothing: subpixel-antialiased;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@ -1976,6 +2004,10 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
|||||||
margin-top: 60px !important;
|
margin-top: 60px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.messages .msg {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
#sidebar,
|
#sidebar,
|
||||||
#footer {
|
#footer {
|
||||||
left: -220px;
|
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) {
|
@media (max-width: 479px) {
|
||||||
.container {
|
.container {
|
||||||
margin: 40px 0 !important;
|
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 */
|
/* Top/Bottom margins + button height + image/button margin */
|
||||||
max-height: calc(100vh - 2 * 10px - 37px - 10px);
|
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 {
|
#image-viewer .open-btn {
|
||||||
|
@ -63,7 +63,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="chat-container" class="window">
|
<div id="chat-container" class="window">
|
||||||
<div id="chat"></div>
|
<div id="chat"></div>
|
||||||
<button id="connection-error" class="btn btn-reconnect">Client connection lost — Click here to reconnect</button>
|
<div id="connection-error"></div>
|
||||||
<form id="form" method="post" action="">
|
<form id="form" method="post" action="">
|
||||||
<div class="input">
|
<div class="input">
|
||||||
<span id="nick">
|
<span id="nick">
|
||||||
@ -225,8 +225,8 @@
|
|||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
<h2>
|
<h2>
|
||||||
Status messages
|
Status messages
|
||||||
<span class="tooltipped tooltipped-n tooltipped-no-delay" aria-label="Joins, parts, kicks, nick changes, and mode changes">
|
<span class="tooltipped tooltipped-n tooltipped-no-delay" aria-label="Joins, parts, kicks, nick changes, away changes, and mode changes">
|
||||||
<button class="extra-help" aria-label="Joins, parts, kicks, nick changes, and mode changes"></button>
|
<button class="extra-help" aria-label="Joins, parts, kicks, nick changes, away changes, and mode changes"></button>
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
@ -264,8 +264,8 @@
|
|||||||
<label for="theme-select" class="sr-only">Theme</label>
|
<label for="theme-select" class="sr-only">Theme</label>
|
||||||
<select id="theme-select" name="theme" class="input">
|
<select id="theme-select" name="theme" class="input">
|
||||||
{{#each themes}}
|
{{#each themes}}
|
||||||
<option value="{{filename}}">
|
<option value="{{name}}">
|
||||||
{{name}}
|
{{displayName}}
|
||||||
</option>
|
</option>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
</select>
|
</select>
|
||||||
@ -392,11 +392,9 @@
|
|||||||
|
|
||||||
<h2>Keyboard Shortcuts</h2>
|
<h2>Keyboard Shortcuts</h2>
|
||||||
|
|
||||||
<h3>On Windows / Linux</h3>
|
|
||||||
|
|
||||||
<div class="help-item">
|
<div class="help-item">
|
||||||
<div class="subject">
|
<div class="subject">
|
||||||
<kbd>Ctrl</kbd> + <kbd>↑</kbd> / <kbd>↓</kbd>
|
<kbd class="key-all">Ctrl</kbd><kbd class="key-apple">⌘</kbd> + <kbd>↑</kbd> / <kbd>↓</kbd>
|
||||||
</div>
|
</div>
|
||||||
<div class="description">
|
<div class="description">
|
||||||
<p>Switch to the previous/next window in the channel list</p>
|
<p>Switch to the previous/next window in the channel list</p>
|
||||||
@ -405,16 +403,7 @@
|
|||||||
|
|
||||||
<div class="help-item">
|
<div class="help-item">
|
||||||
<div class="subject">
|
<div class="subject">
|
||||||
<kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>L</kbd>
|
<kbd class="key-all">Ctrl</kbd><kbd class="key-apple">⌘</kbd> + <kbd>K</kbd>
|
||||||
</div>
|
|
||||||
<div class="description">
|
|
||||||
<p>Clear the current screen</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="help-item">
|
|
||||||
<div class="subject">
|
|
||||||
<kbd>Ctrl</kbd> + <kbd>K</kbd>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="description">
|
<div class="description">
|
||||||
<p>
|
<p>
|
||||||
@ -431,7 +420,7 @@
|
|||||||
|
|
||||||
<div class="help-item">
|
<div class="help-item">
|
||||||
<div class="subject">
|
<div class="subject">
|
||||||
<kbd>Ctrl</kbd> + <kbd>B</kbd>
|
<kbd class="key-all">Ctrl</kbd><kbd class="key-apple">⌘</kbd> + <kbd>B</kbd>
|
||||||
</div>
|
</div>
|
||||||
<div class="description">
|
<div class="description">
|
||||||
<p>Mark all text typed after this shortcut as bold.</p>
|
<p>Mark all text typed after this shortcut as bold.</p>
|
||||||
@ -440,7 +429,7 @@
|
|||||||
|
|
||||||
<div class="help-item">
|
<div class="help-item">
|
||||||
<div class="subject">
|
<div class="subject">
|
||||||
<kbd>Ctrl</kbd> + <kbd>U</kbd>
|
<kbd class="key-all">Ctrl</kbd><kbd class="key-apple">⌘</kbd> + <kbd>U</kbd>
|
||||||
</div>
|
</div>
|
||||||
<div class="description">
|
<div class="description">
|
||||||
<p>Mark all text typed after this shortcut as underlined.</p>
|
<p>Mark all text typed after this shortcut as underlined.</p>
|
||||||
@ -449,7 +438,7 @@
|
|||||||
|
|
||||||
<div class="help-item">
|
<div class="help-item">
|
||||||
<div class="subject">
|
<div class="subject">
|
||||||
<kbd>Ctrl</kbd> + <kbd>I</kbd>
|
<kbd class="key-all">Ctrl</kbd><kbd class="key-apple">⌘</kbd> + <kbd>I</kbd>
|
||||||
</div>
|
</div>
|
||||||
<div class="description">
|
<div class="description">
|
||||||
<p>Mark all text typed after this shortcut as italics.</p>
|
<p>Mark all text typed after this shortcut as italics.</p>
|
||||||
@ -458,83 +447,7 @@
|
|||||||
|
|
||||||
<div class="help-item">
|
<div class="help-item">
|
||||||
<div class="subject">
|
<div class="subject">
|
||||||
<kbd>Ctrl</kbd> + <kbd>O</kbd>
|
<kbd class="key-all">Ctrl</kbd><kbd class="key-apple">⌘</kbd> + <kbd>O</kbd>
|
||||||
</div>
|
|
||||||
<div class="description">
|
|
||||||
<p>
|
|
||||||
Mark all text typed after this shortcut to be reset to its
|
|
||||||
original formatting.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3>On macOS</h3>
|
|
||||||
|
|
||||||
<div class="help-item">
|
|
||||||
<div class="subject">
|
|
||||||
<kbd>⌘</kbd> + <kbd>↑</kbd> / <kbd>↓</kbd>
|
|
||||||
</div>
|
|
||||||
<div class="description">
|
|
||||||
<p>Switch to the previous/next window in the channel list</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="help-item">
|
|
||||||
<div class="subject">
|
|
||||||
<kbd>⇧</kbd> + <kbd>⌘</kbd> + <kbd>L</kbd>
|
|
||||||
</div>
|
|
||||||
<div class="description">
|
|
||||||
<p>Clear the current screen</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="help-item">
|
|
||||||
<div class="subject">
|
|
||||||
<kbd>⌘</kbd> + <kbd>K</kbd>
|
|
||||||
</div>
|
|
||||||
<div class="description">
|
|
||||||
<p>
|
|
||||||
Mark any text typed after this shortcut to be colored. After
|
|
||||||
hitting this shortcut, enter an integer in the
|
|
||||||
<code>0—15</code> range to select the desired color.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
A color reference can be found
|
|
||||||
<a href="https://modern.ircdocs.horse/formatting.html#colors" target="_blank" rel="noopener">here</a>.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="help-item">
|
|
||||||
<div class="subject">
|
|
||||||
<kbd>⌘</kbd> + <kbd>B</kbd>
|
|
||||||
</div>
|
|
||||||
<div class="description">
|
|
||||||
<p>Mark all text typed after this shortcut as bold.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="help-item">
|
|
||||||
<div class="subject">
|
|
||||||
<kbd>⌘</kbd> + <kbd>U</kbd>
|
|
||||||
</div>
|
|
||||||
<div class="description">
|
|
||||||
<p>Mark all text typed after this shortcut as underlined.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="help-item">
|
|
||||||
<div class="subject">
|
|
||||||
<kbd>⌘</kbd> + <kbd>I</kbd>
|
|
||||||
</div>
|
|
||||||
<div class="description">
|
|
||||||
<p>Mark all text typed after this shortcut as italics.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="help-item">
|
|
||||||
<div class="subject">
|
|
||||||
<kbd>⌘</kbd> + <kbd>O</kbd>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="description">
|
<div class="description">
|
||||||
<p>
|
<p>
|
||||||
@ -583,15 +496,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="help-item">
|
|
||||||
<div class="subject">
|
|
||||||
<code>/clear</code>
|
|
||||||
</div>
|
|
||||||
<div class="description">
|
|
||||||
<p>Clear the current screen.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="help-item">
|
<div class="help-item">
|
||||||
<div class="subject">
|
<div class="subject">
|
||||||
<code>/collapse</code>
|
<code>/collapse</code>
|
||||||
@ -787,10 +691,13 @@
|
|||||||
|
|
||||||
<div class="help-item">
|
<div class="help-item">
|
||||||
<div class="subject">
|
<div class="subject">
|
||||||
<code>/part</code>
|
<code>/part [channel]</code>
|
||||||
</div>
|
</div>
|
||||||
<div class="description">
|
<div class="description">
|
||||||
<p>Close the current channel or private message window.</p>
|
<p>
|
||||||
|
Close the specified channel or private message window, or the
|
||||||
|
current channel if <code>channel</code> is ommitted.
|
||||||
|
</p>
|
||||||
<p>Aliases: <code>/close</code>, <code>/leave</code></p>
|
<p>Aliases: <code>/close</code>, <code>/leave</code></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
38
client/js/clipboard.js
Normal file
38
client/js/clipboard.js
Normal file
@ -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);
|
@ -23,6 +23,12 @@ function updateText(condensed, addedTypes) {
|
|||||||
constants.condensedTypes.forEach((type) => {
|
constants.condensedTypes.forEach((type) => {
|
||||||
if (obj[type]) {
|
if (obj[type]) {
|
||||||
switch (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":
|
case "join":
|
||||||
strings.push(obj[type] + (obj[type] > 1 ? " users have joined the channel" : " user has joined the channel"));
|
strings.push(obj[type] + (obj[type] > 1 ? " users have joined the channel" : " user has joined the channel"));
|
||||||
break;
|
break;
|
||||||
|
@ -37,6 +37,7 @@ const commands = [
|
|||||||
"/join",
|
"/join",
|
||||||
"/kick",
|
"/kick",
|
||||||
"/leave",
|
"/leave",
|
||||||
|
"/list",
|
||||||
"/me",
|
"/me",
|
||||||
"/mode",
|
"/mode",
|
||||||
"/msg",
|
"/msg",
|
||||||
@ -59,6 +60,8 @@ const commands = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const actionTypes = [
|
const actionTypes = [
|
||||||
|
"away",
|
||||||
|
"back",
|
||||||
"ban_list",
|
"ban_list",
|
||||||
"invite",
|
"invite",
|
||||||
"join",
|
"join",
|
||||||
@ -76,6 +79,8 @@ const actionTypes = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const condensedTypes = [
|
const condensedTypes = [
|
||||||
|
"away",
|
||||||
|
"back",
|
||||||
"join",
|
"join",
|
||||||
"part",
|
"part",
|
||||||
"quit",
|
"quit",
|
||||||
|
101
client/js/keybinds.js
Normal file
101
client/js/keybinds.js
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
}
|
@ -4,24 +4,23 @@
|
|||||||
require("jquery-ui/ui/widgets/sortable");
|
require("jquery-ui/ui/widgets/sortable");
|
||||||
const $ = require("jquery");
|
const $ = require("jquery");
|
||||||
const moment = require("moment");
|
const moment = require("moment");
|
||||||
const Mousetrap = require("mousetrap");
|
|
||||||
const URI = require("urijs");
|
const URI = require("urijs");
|
||||||
const fuzzy = require("fuzzy");
|
const fuzzy = require("fuzzy");
|
||||||
|
|
||||||
// our libraries
|
// our libraries
|
||||||
require("./libs/jquery/inputhistory");
|
require("./libs/jquery/inputhistory");
|
||||||
require("./libs/jquery/stickyscroll");
|
require("./libs/jquery/stickyscroll");
|
||||||
const helpers_roundBadgeNumber = require("./libs/handlebars/roundBadgeNumber");
|
|
||||||
const slideoutMenu = require("./libs/slideout");
|
const slideoutMenu = require("./libs/slideout");
|
||||||
const templates = require("../views");
|
const templates = require("../views");
|
||||||
const socket = require("./socket");
|
const socket = require("./socket");
|
||||||
require("./socket-events");
|
require("./socket-events");
|
||||||
const storage = require("./localStorage");
|
const storage = require("./localStorage");
|
||||||
const options = require("./options");
|
require("./options");
|
||||||
const utils = require("./utils");
|
const utils = require("./utils");
|
||||||
const modules = require("./modules");
|
|
||||||
require("./autocompletion");
|
require("./autocompletion");
|
||||||
require("./webpush");
|
require("./webpush");
|
||||||
|
require("./keybinds");
|
||||||
|
require("./clipboard");
|
||||||
|
|
||||||
$(function() {
|
$(function() {
|
||||||
var sidebar = $("#sidebar, #footer");
|
var sidebar = $("#sidebar, #footer");
|
||||||
@ -29,20 +28,6 @@ $(function() {
|
|||||||
|
|
||||||
$(document.body).data("app-name", document.title);
|
$(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 windows = $("#windows");
|
||||||
var viewport = $("#viewport");
|
var viewport = $("#viewport");
|
||||||
var sidebarSlide = slideoutMenu(viewport[0], sidebar[0]);
|
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) {
|
$("#form").on("submit", function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
utils.forceFocus();
|
utils.forceFocus();
|
||||||
@ -199,22 +188,19 @@ $(function() {
|
|||||||
const separatorPos = text.indexOf(" ");
|
const separatorPos = text.indexOf(" ");
|
||||||
const cmd = text.substring(1, separatorPos > 1 ? separatorPos : text.length);
|
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) : "";
|
const parameters = separatorPos > text.indexOf(cmd) ? text.substring(text.indexOf(cmd) + cmd.length + 1, text.length) : "";
|
||||||
switch (cmd) {
|
if (typeof utils[cmd] === "function") {
|
||||||
case "clear":
|
if (cmd === "join") {
|
||||||
if (modules.clear()) return;
|
|
||||||
break;
|
|
||||||
case "collapse":
|
|
||||||
if (modules.collapse()) return;
|
|
||||||
break;
|
|
||||||
case "expand":
|
|
||||||
if (modules.expand()) return;
|
|
||||||
break;
|
|
||||||
case "join":
|
|
||||||
const channel = parameters.split(" ")[0];
|
const channel = parameters.split(" ")[0];
|
||||||
if (channel != "") {
|
if (channel !== "") {
|
||||||
if (modules.join(channel)) return;
|
if (utils[cmd](channel)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (utils[cmd]()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -327,16 +313,16 @@ $(function() {
|
|||||||
const state = {};
|
const state = {};
|
||||||
|
|
||||||
if (self.hasClass("chan")) {
|
if (self.hasClass("chan")) {
|
||||||
state.clickTarget = `.chan[data-id="${self.data("id")}"]`;
|
state.clickTarget = `#sidebar .chan[data-id="${self.data("id")}"]`;
|
||||||
} else {
|
} else {
|
||||||
state.clickTarget = `#footer button[data-target="${target}"]`;
|
state.clickTarget = `#footer button[data-target="${target}"]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (history && history.pushState) {
|
if (history && history.pushState) {
|
||||||
if (data && data.replaceHistory && history.replaceState) {
|
if (data && data.replaceHistory && history.replaceState) {
|
||||||
history.replaceState(state, null, null);
|
history.replaceState(state, null, target);
|
||||||
} else {
|
} else {
|
||||||
history.pushState(state, null, null);
|
history.pushState(state, null, target);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -382,6 +368,7 @@ $(function() {
|
|||||||
|
|
||||||
lastActiveChan
|
lastActiveChan
|
||||||
.find(".unread-marker")
|
.find(".unread-marker")
|
||||||
|
.data("unread-id", 0)
|
||||||
.appendTo(lastActiveChan.find(".messages"));
|
.appendTo(lastActiveChan.find(".messages"));
|
||||||
|
|
||||||
var chan = $(target)
|
var chan = $(target)
|
||||||
@ -433,7 +420,7 @@ $(function() {
|
|||||||
if (chan.hasClass("lobby")) {
|
if (chan.hasClass("lobby")) {
|
||||||
cmd = "/quit";
|
cmd = "/quit";
|
||||||
var server = chan.find(".name").html();
|
var server = chan.find(".name").html();
|
||||||
if (!confirm("Disconnect from " + server + "?")) {
|
if (!confirm("Disconnect from " + server + "?")) { // eslint-disable-line no-alert
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -490,95 +477,6 @@ $(function() {
|
|||||||
container.html(templates.user_filtered({matches: result})).show();
|
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");
|
var forms = $("#sign-in, #connect, #change-password");
|
||||||
|
|
||||||
windows.on("show", "#sign-in", function() {
|
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() {
|
$("#connect").one("show", function() {
|
||||||
var params = URI(document.location.search);
|
var params = URI(document.location.search);
|
||||||
params = params.search(true);
|
params = params.search(true);
|
||||||
@ -620,23 +519,25 @@ $(function() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
var event = "auth";
|
var event = "auth";
|
||||||
var form = $(this);
|
var form = $(this);
|
||||||
form.find(".btn")
|
form.find(".btn").attr("disabled", true);
|
||||||
.attr("disabled", true)
|
|
||||||
.end();
|
|
||||||
if (form.closest(".window").attr("id") === "connect") {
|
if (form.closest(".window").attr("id") === "connect") {
|
||||||
event = "conn";
|
event = "conn";
|
||||||
} else if (form.closest("div").attr("id") === "change-password") {
|
} else if (form.closest("div").attr("id") === "change-password") {
|
||||||
event = "change-password";
|
event = "change-password";
|
||||||
}
|
}
|
||||||
|
|
||||||
var values = {};
|
var values = {};
|
||||||
$.each(form.serializeArray(), function(i, obj) {
|
$.each(form.serializeArray(), function(i, obj) {
|
||||||
if (obj.value !== "") {
|
if (obj.value !== "") {
|
||||||
values[obj.name] = obj.value;
|
values[obj.name] = obj.value;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (values.user) {
|
if (values.user) {
|
||||||
storage.set("user", values.user);
|
storage.set("user", values.user);
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.emit(
|
socket.emit(
|
||||||
event, values
|
event, values
|
||||||
);
|
);
|
||||||
@ -664,111 +565,6 @@ $(function() {
|
|||||||
$(this).data("lastvalue", nick);
|
$(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", () => {
|
$(document).on("visibilitychange focus click", () => {
|
||||||
if (sidebar.find(".highlight").length === 0) {
|
if (sidebar.find(".highlight").length === 0) {
|
||||||
utils.toggleNotificationMarkers(false);
|
utils.toggleNotificationMarkers(false);
|
||||||
@ -786,7 +582,7 @@ $(function() {
|
|||||||
$(".date-marker-text[data-label='Today'], .date-marker-text[data-label='Yesterday']")
|
$(".date-marker-text[data-label='Today'], .date-marker-text[data-label='Yesterday']")
|
||||||
.closest(".date-marker-container")
|
.closest(".date-marker-container")
|
||||||
.each(function() {
|
.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
|
// This should always be 24h later but re-computing exact value just in case
|
||||||
@ -794,20 +590,33 @@ $(function() {
|
|||||||
}
|
}
|
||||||
setTimeout(updateDateMarkers, msUntilNextDay());
|
setTimeout(updateDateMarkers, msUntilNextDay());
|
||||||
|
|
||||||
// Only start opening socket.io connection after all events have been registered
|
|
||||||
socket.open();
|
|
||||||
|
|
||||||
window.addEventListener("popstate", (e) => {
|
window.addEventListener("popstate", (e) => {
|
||||||
const {state} = e;
|
const {state} = e;
|
||||||
if (!state) {
|
if (!state) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {clickTarget} = state;
|
let {clickTarget} = state;
|
||||||
|
|
||||||
if (clickTarget) {
|
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", {
|
$(clickTarget).trigger("click", {
|
||||||
pushState: false
|
pushState: false
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Only start opening socket.io connection after all events have been registered
|
||||||
|
socket.open();
|
||||||
});
|
});
|
||||||
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -8,6 +8,7 @@ const utils = require("./utils");
|
|||||||
const sorting = require("./sorting");
|
const sorting = require("./sorting");
|
||||||
const constants = require("./constants");
|
const constants = require("./constants");
|
||||||
const condensed = require("./condensed");
|
const condensed = require("./condensed");
|
||||||
|
const helpers_parse = require("./libs/handlebars/parse");
|
||||||
|
|
||||||
const chat = $("#chat");
|
const chat = $("#chat");
|
||||||
const sidebar = $("#sidebar");
|
const sidebar = $("#sidebar");
|
||||||
@ -27,45 +28,29 @@ module.exports = {
|
|||||||
renderNetworks,
|
renderNetworks,
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildChannelMessages(chanId, chanType, messages) {
|
function buildChannelMessages(container, chanId, chanType, messages) {
|
||||||
return messages.reduce((docFragment, message) => {
|
return messages.reduce((docFragment, message) => {
|
||||||
appendMessage(docFragment, chanId, chanType, message);
|
appendMessage(docFragment, chanId, chanType, message);
|
||||||
return docFragment;
|
return docFragment;
|
||||||
}, $(document.createDocumentFragment()));
|
}, container);
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendMessage(container, chanId, chanType, msg) {
|
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
|
// Check if date changed
|
||||||
let lastChild = container.find(".msg").last();
|
|
||||||
const msgTime = new Date(msg.time);
|
const msgTime = new Date(msg.time);
|
||||||
|
const prevMsgTime = new Date(lastChild.data("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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert date marker if date changed compared to previous message
|
// Insert date marker if date changed compared to previous message
|
||||||
if (prevMsgTime.toDateString() !== msgTime.toDateString()) {
|
if (prevMsgTime.toDateString() !== msgTime.toDateString()) {
|
||||||
lastChild.after(templates.date_marker({msgDate: msgTime}));
|
lastChild = $(templates.date_marker({time: msg.time}));
|
||||||
|
container.append(lastChild);
|
||||||
// If date changed, we don't need to do condensed logic
|
|
||||||
container.append(renderedMessage);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If current window is not a channel or this message is not condensable,
|
// If current window is not a channel or this message is not condensable,
|
||||||
@ -83,25 +68,16 @@ function appendMessage(container, chanId, chanType, msg) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newCondensed = buildChatMessage(chanId, {
|
// Always create a condensed container
|
||||||
type: "condensed",
|
const newCondensed = $(templates.msg_condensed({time: msg.time}));
|
||||||
time: msg.time,
|
|
||||||
previews: []
|
|
||||||
});
|
|
||||||
|
|
||||||
condensed.updateText(newCondensed, [msg.type]);
|
condensed.updateText(newCondensed, [msg.type]);
|
||||||
newCondensed.append(renderedMessage);
|
newCondensed.append(renderedMessage);
|
||||||
container.append(newCondensed);
|
container.append(newCondensed);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildChatMessage(chanId, msg) {
|
function buildChatMessage(msg) {
|
||||||
const type = msg.type;
|
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";
|
let template = "msg";
|
||||||
|
|
||||||
// See if any of the custom highlight regexes match
|
// See if any of the custom highlight regexes match
|
||||||
@ -117,8 +93,6 @@ function buildChatMessage(chanId, msg) {
|
|||||||
template = "msg_action";
|
template = "msg_action";
|
||||||
} else if (type === "unhandled") {
|
} else if (type === "unhandled") {
|
||||||
template = "msg_unhandled";
|
template = "msg_unhandled";
|
||||||
} else if (type === "condensed") {
|
|
||||||
template = "msg_condensed";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderedMessage = $(templates[template](msg));
|
const renderedMessage = $(templates[template](msg));
|
||||||
@ -132,17 +106,6 @@ function buildChatMessage(chanId, msg) {
|
|||||||
renderPreview(preview, renderedMessage);
|
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;
|
return renderedMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -159,22 +122,28 @@ function renderChannel(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderChannelMessages(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);
|
const channel = chat.find("#chan-" + data.id + " .messages").append(documentFragment);
|
||||||
|
|
||||||
if (data.firstUnread > 0) {
|
const template = $(templates.unread_marker());
|
||||||
const first = channel.find("#msg-" + data.firstUnread);
|
|
||||||
|
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) {
|
if (!first.length) {
|
||||||
channel.prepend(templates.unread_marker());
|
template.data("unread-id", data.firstUnread);
|
||||||
} else if (first.parent().hasClass("condensed")) {
|
channel.prepend(template);
|
||||||
first.parent().before(templates.unread_marker());
|
|
||||||
} else {
|
} else {
|
||||||
first.before(templates.unread_marker());
|
const parent = first.parent();
|
||||||
|
|
||||||
|
if (parent.hasClass("condensed")) {
|
||||||
|
first = parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
first.before(template);
|
||||||
}
|
}
|
||||||
} else {
|
} 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(".empty").hide();
|
||||||
sidebar.find(".networks").append(
|
sidebar.find(".networks").append(
|
||||||
templates.network({
|
templates.network({
|
||||||
@ -208,15 +177,51 @@ function renderNetworks(data) {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let newChannels;
|
||||||
const channels = $.map(data.networks, function(n) {
|
const channels = $.map(data.networks, function(n) {
|
||||||
return n.channels;
|
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(
|
chat.append(
|
||||||
templates.chat({
|
templates.chat({
|
||||||
channels: channels
|
channels: channels
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
channels.forEach((channel) => {
|
|
||||||
|
newChannels.forEach((channel) => {
|
||||||
renderChannel(channel);
|
renderChannel(channel);
|
||||||
|
|
||||||
if (channel.type === "channel") {
|
if (channel.type === "channel") {
|
||||||
|
@ -92,10 +92,12 @@ function handleImageInPreview(content, container) {
|
|||||||
|
|
||||||
const imageViewer = $("#image-viewer");
|
const imageViewer = $("#image-viewer");
|
||||||
|
|
||||||
$("#chat").on("click", ".toggle-thumbnail", function() {
|
$("#chat").on("click", ".toggle-thumbnail", function(event, data = {}) {
|
||||||
const link = $(this);
|
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,
|
// 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.
|
// but keep it a link to allow for Ctrl/Cmd+click.
|
||||||
@ -103,8 +105,10 @@ $("#chat").on("click", ".toggle-thumbnail", function() {
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
imageViewer.on("click", function() {
|
imageViewer.on("click", function(event, data = {}) {
|
||||||
closeImageViewer();
|
// 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) {
|
$(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");
|
$(".previous-image").removeClass("previous-image");
|
||||||
$(".next-image").removeClass("next-image");
|
$(".next-image").removeClass("next-image");
|
||||||
|
|
||||||
@ -161,7 +165,20 @@ function openImageViewer(link) {
|
|||||||
hasNextImage: nextImage.length > 0,
|
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() {
|
imageViewer.on("click", ".previous-image-btn", function() {
|
||||||
@ -174,7 +191,7 @@ imageViewer.on("click", ".next-image-btn", function() {
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
function closeImageViewer() {
|
function closeImageViewer({pushState = true} = {}) {
|
||||||
imageViewer
|
imageViewer
|
||||||
.removeClass("opened")
|
.removeClass("opened")
|
||||||
.one("transitionend", function() {
|
.one("transitionend", function() {
|
||||||
@ -182,4 +199,12 @@ function closeImageViewer() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
input.focus();
|
input.focus();
|
||||||
|
|
||||||
|
// History management
|
||||||
|
if (pushState) {
|
||||||
|
const clickTarget =
|
||||||
|
"#sidebar " +
|
||||||
|
`.chan[data-id="${$("#sidebar .chan.active").data("id")}"]`;
|
||||||
|
history.pushState({clickTarget}, null, null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,8 +3,20 @@
|
|||||||
const $ = require("jquery");
|
const $ = require("jquery");
|
||||||
const socket = require("../socket");
|
const socket = require("../socket");
|
||||||
const storage = require("../localStorage");
|
const storage = require("../localStorage");
|
||||||
|
const utils = require("../utils");
|
||||||
|
|
||||||
socket.on("auth", function(data) {
|
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");
|
const login = $("#sign-in");
|
||||||
let token;
|
let token;
|
||||||
const user = storage.get("user");
|
const user = storage.get("user");
|
||||||
@ -12,6 +24,13 @@ socket.on("auth", function(data) {
|
|||||||
login.find(".btn").prop("disabled", false);
|
login.find(".btn").prop("disabled", false);
|
||||||
|
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
|
if (login.length === 0) {
|
||||||
|
socket.disconnect();
|
||||||
|
$("#connection-error").text("Authentication failed, reloading…");
|
||||||
|
location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
storage.remove("token");
|
storage.remove("token");
|
||||||
|
|
||||||
const error = login.find(".error");
|
const error = login.find(".error");
|
||||||
@ -20,9 +39,15 @@ socket.on("auth", function(data) {
|
|||||||
});
|
});
|
||||||
} else if (user) {
|
} else if (user) {
|
||||||
token = storage.get("token");
|
token = storage.get("token");
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
$("#loading-page-message").text("Authorizing…");
|
$("#loading-page-message, #connection-error").text("Authorizing…");
|
||||||
socket.emit("auth", {user: user, token: token});
|
|
||||||
|
socket.emit("auth", {
|
||||||
|
user: user,
|
||||||
|
token: token,
|
||||||
|
lastMessage: utils.lastMessageId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -34,13 +59,9 @@ socket.on("auth", function(data) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$("#footer").find(".sign-in")
|
$("#footer")
|
||||||
|
.find(".sign-in")
|
||||||
.trigger("click", {
|
.trigger("click", {
|
||||||
pushState: false,
|
pushState: false,
|
||||||
})
|
});
|
||||||
.end()
|
|
||||||
.find(".networks")
|
|
||||||
.html("")
|
|
||||||
.next()
|
|
||||||
.show();
|
|
||||||
});
|
});
|
||||||
|
@ -1,16 +1,28 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const $ = require("jquery");
|
const $ = require("jquery");
|
||||||
|
const escape = require("css.escape");
|
||||||
const socket = require("../socket");
|
const socket = require("../socket");
|
||||||
const render = require("../render");
|
const render = require("../render");
|
||||||
const webpush = require("../webpush");
|
const webpush = require("../webpush");
|
||||||
const sidebar = $("#sidebar");
|
const sidebar = $("#sidebar");
|
||||||
const storage = require("../localStorage");
|
const storage = require("../localStorage");
|
||||||
|
const utils = require("../utils");
|
||||||
|
|
||||||
socket.on("init", function(data) {
|
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) {
|
if (data.networks.length === 0) {
|
||||||
|
sidebar.find(".empty").show();
|
||||||
|
|
||||||
$("#footer").find(".connect").trigger("click", {
|
$("#footer").find(".connect").trigger("click", {
|
||||||
pushState: false,
|
pushState: false,
|
||||||
});
|
});
|
||||||
@ -18,6 +30,11 @@ socket.on("init", function(data) {
|
|||||||
render.renderNetworks(data);
|
render.renderNetworks(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (lastMessageId > -1) {
|
||||||
|
$("#connection-error").removeClass("shown");
|
||||||
|
$(".show-more-button, #input").prop("disabled", false);
|
||||||
|
$("#submit").show();
|
||||||
|
} else {
|
||||||
if (data.token) {
|
if (data.token) {
|
||||||
storage.set("token", data.token);
|
storage.set("token", data.token);
|
||||||
}
|
}
|
||||||
@ -27,19 +44,45 @@ socket.on("init", function(data) {
|
|||||||
$("body").removeClass("signed-out");
|
$("body").removeClass("signed-out");
|
||||||
$("#loading").remove();
|
$("#loading").remove();
|
||||||
$("#sign-in").remove();
|
$("#sign-in").remove();
|
||||||
|
}
|
||||||
|
|
||||||
const id = data.active;
|
openCorrectChannel(previousActive, data.active);
|
||||||
const target = sidebar.find("[data-id='" + id + "']").trigger("click", {
|
});
|
||||||
|
|
||||||
|
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
|
replaceHistory: true
|
||||||
});
|
});
|
||||||
if (target.length === 0) {
|
|
||||||
const first = sidebar.find(".chan")
|
return;
|
||||||
.eq(0)
|
}
|
||||||
.trigger("click");
|
|
||||||
if (first.length === 0) {
|
// Open the connect window
|
||||||
$("#footer").find(".connect").trigger("click", {
|
$("#footer .connect").trigger("click", {
|
||||||
pushState: false,
|
pushState: false
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
|
@ -33,8 +33,30 @@ socket.on("more", function(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add the older messages
|
// Add the older messages
|
||||||
const documentFragment = render.buildChannelMessages(data.chan, type, data.messages);
|
const documentFragment = render.buildChannelMessages($(document.createDocumentFragment()), data.chan, type, data.messages);
|
||||||
chan.prepend(documentFragment).end();
|
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
|
// restore scroll position
|
||||||
const position = chan.height() - heightOld;
|
const position = chan.height() - heightOld;
|
||||||
@ -54,3 +76,22 @@ socket.on("more", function(data) {
|
|||||||
.text("Show older messages")
|
.text("Show older messages")
|
||||||
.prop("disabled", false);
|
.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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
@ -3,17 +3,27 @@
|
|||||||
const $ = require("jquery");
|
const $ = require("jquery");
|
||||||
const socket = require("../socket");
|
const socket = require("../socket");
|
||||||
const render = require("../render");
|
const render = require("../render");
|
||||||
|
const utils = require("../utils");
|
||||||
|
const options = require("../options");
|
||||||
|
const helpers_roundBadgeNumber = require("../libs/handlebars/roundBadgeNumber");
|
||||||
const chat = $("#chat");
|
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) {
|
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.
|
// We set a maximum timeout of 2 seconds so that messages don't take too long to appear.
|
||||||
window.requestIdleCallback(() => processReceivedMessage(data), {timeout: 2000});
|
utils.requestIdleCallback(() => processReceivedMessage(data), 2000);
|
||||||
} else {
|
|
||||||
processReceivedMessage(data);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function processReceivedMessage(data) {
|
function processReceivedMessage(data) {
|
||||||
@ -32,14 +42,13 @@ function processReceivedMessage(data) {
|
|||||||
render.appendMessage(
|
render.appendMessage(
|
||||||
container,
|
container,
|
||||||
targetId,
|
targetId,
|
||||||
$(target).attr("data-type"),
|
channel.attr("data-type"),
|
||||||
data.msg
|
data.msg
|
||||||
);
|
);
|
||||||
|
|
||||||
container.trigger("msg", [
|
container.trigger("keepToBottom");
|
||||||
target,
|
|
||||||
data
|
notifyMessage(targetId, channel, data);
|
||||||
]).trigger("keepToBottom");
|
|
||||||
|
|
||||||
var lastVisible = container.find("div:visible").last();
|
var lastVisible = container.find("div:visible").last();
|
||||||
if (data.msg.self
|
if (data.msg.self
|
||||||
@ -48,6 +57,7 @@ function processReceivedMessage(data) {
|
|||||||
&& lastVisible.prev().hasClass("unread-marker"))) {
|
&& lastVisible.prev().hasClass("unread-marker"))) {
|
||||||
container
|
container
|
||||||
.find(".unread-marker")
|
.find(".unread-marker")
|
||||||
|
.data("unread-id", 0)
|
||||||
.appendTo(container);
|
.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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,9 +3,9 @@
|
|||||||
const $ = require("jquery");
|
const $ = require("jquery");
|
||||||
const renderPreview = require("../renderPreview");
|
const renderPreview = require("../renderPreview");
|
||||||
const socket = require("../socket");
|
const socket = require("../socket");
|
||||||
|
const utils = require("../utils");
|
||||||
|
|
||||||
socket.on("msg:preview", function(data) {
|
socket.on("msg:preview", function(data) {
|
||||||
const msg = $("#msg-" + data.id);
|
// Previews are not as important, we can wait longer for them to appear
|
||||||
|
utils.requestIdleCallback(() => renderPreview(data.preview, $("#msg-" + data.id)), 6000);
|
||||||
renderPreview(data.preview, msg);
|
|
||||||
});
|
});
|
||||||
|
@ -6,7 +6,7 @@ const render = require("../render");
|
|||||||
const sidebar = $("#sidebar");
|
const sidebar = $("#sidebar");
|
||||||
|
|
||||||
socket.on("network", function(data) {
|
socket.on("network", function(data) {
|
||||||
render.renderNetworks(data);
|
render.renderNetworks(data, true);
|
||||||
|
|
||||||
sidebar.find(".chan")
|
sidebar.find(".chan")
|
||||||
.last()
|
.last()
|
||||||
@ -14,11 +14,9 @@ socket.on("network", function(data) {
|
|||||||
|
|
||||||
$("#connect")
|
$("#connect")
|
||||||
.find(".btn")
|
.find(".btn")
|
||||||
.prop("disabled", false)
|
.prop("disabled", false);
|
||||||
.end();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on("network_changed", function(data) {
|
socket.on("network_changed", function(data) {
|
||||||
sidebar.find("#network-" + data.network).data("options", data.serverOptions);
|
sidebar.find("#network-" + data.network).data("options", data.serverOptions);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -6,12 +6,12 @@ const sidebar = $("#sidebar");
|
|||||||
|
|
||||||
socket.on("quit", function(data) {
|
socket.on("quit", function(data) {
|
||||||
const id = data.network;
|
const id = data.network;
|
||||||
sidebar.find("#network-" + id)
|
sidebar.find("#network-" + id).remove();
|
||||||
.remove()
|
|
||||||
.end();
|
|
||||||
const chan = sidebar.find(".chan")
|
const chan = sidebar.find(".chan")
|
||||||
.eq(0)
|
.eq(0)
|
||||||
.trigger("click");
|
.trigger("click");
|
||||||
|
|
||||||
if (chan.length === 0) {
|
if (chan.length === 0) {
|
||||||
sidebar.find(".empty").show();
|
sidebar.find(".empty").show();
|
||||||
}
|
}
|
||||||
|
@ -2,54 +2,54 @@
|
|||||||
|
|
||||||
const $ = require("jquery");
|
const $ = require("jquery");
|
||||||
const io = require("socket.io-client");
|
const io = require("socket.io-client");
|
||||||
|
const utils = require("./utils");
|
||||||
const path = window.location.pathname + "socket.io/";
|
const path = window.location.pathname + "socket.io/";
|
||||||
|
const status = $("#loading-page-message, #connection-error");
|
||||||
|
|
||||||
const socket = io({
|
const socket = io({
|
||||||
transports: $(document.body).data("transports"),
|
transports: $(document.body).data("transports"),
|
||||||
path: path,
|
path: path,
|
||||||
autoConnect: false,
|
autoConnect: false,
|
||||||
reconnection: false
|
reconnection: !$(document.body).hasClass("public")
|
||||||
});
|
});
|
||||||
|
|
||||||
[
|
socket.on("disconnect", handleDisconnect);
|
||||||
"connect_error",
|
socket.on("connect_error", handleDisconnect);
|
||||||
"connect_failed",
|
socket.on("error", handleDisconnect);
|
||||||
"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();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Disables sending a message by pressing Enter. `off` is necessary to
|
socket.on("reconnecting", function(attempt) {
|
||||||
// cancel `inputhistory`, which overrides hitting Enter. `on` is then
|
status.text(`Reconnecting… (attempt ${attempt})`);
|
||||||
// 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("connecting", function() {
|
socket.on("connecting", function() {
|
||||||
$("#loading-page-message").text("Connecting…");
|
status.text("Connecting…");
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on("connect", function() {
|
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() {
|
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;
|
module.exports = socket;
|
||||||
|
@ -4,16 +4,25 @@ const $ = require("jquery");
|
|||||||
const chat = $("#chat");
|
const chat = $("#chat");
|
||||||
const input = $("#input");
|
const input = $("#input");
|
||||||
|
|
||||||
|
var serverHash = -1;
|
||||||
|
var lastMessageId = -1;
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
findCurrentNetworkChan,
|
findCurrentNetworkChan,
|
||||||
clear,
|
clear,
|
||||||
|
collapse,
|
||||||
|
expand,
|
||||||
|
join,
|
||||||
|
serverHash,
|
||||||
|
lastMessageId,
|
||||||
confirmExit,
|
confirmExit,
|
||||||
forceFocus,
|
forceFocus,
|
||||||
move,
|
move,
|
||||||
resetHeight,
|
resetHeight,
|
||||||
setNick,
|
setNick,
|
||||||
toggleNickEditor,
|
toggleNickEditor,
|
||||||
toggleNotificationMarkers
|
toggleNotificationMarkers,
|
||||||
|
requestIdleCallback,
|
||||||
};
|
};
|
||||||
|
|
||||||
function findCurrentNetworkChan(name) {
|
function findCurrentNetworkChan(name) {
|
||||||
@ -42,6 +51,26 @@ function clear() {
|
|||||||
chat.find(".active")
|
chat.find(".active")
|
||||||
.find(".show-more").addClass("show").end()
|
.find(".show-more").addClass("show").end()
|
||||||
.find(".messages .msg, .date-marker-container").remove();
|
.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) {
|
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]);
|
array.splice(new_index, 0, array.splice(old_index, 1)[0]);
|
||||||
return array;
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -9,16 +9,30 @@ self.addEventListener("push", function(event) {
|
|||||||
|
|
||||||
const payload = event.data.json();
|
const payload = event.data.json();
|
||||||
|
|
||||||
if (payload.type === "notification") {
|
if (payload.type !== "notification") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// get current notification, close it, and draw new
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
self.registration.showNotification(payload.title, {
|
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",
|
badge: "img/logo-64.png",
|
||||||
icon: "img/touch-icon-192x192.png",
|
icon: "img/touch-icon-192x192.png",
|
||||||
body: payload.body,
|
body: payload.body,
|
||||||
timestamp: payload.timestamp,
|
timestamp: payload.timestamp,
|
||||||
|
});
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener("notificationclick", function(event) {
|
self.addEventListener("notificationclick", function(event) {
|
||||||
|
@ -65,12 +65,8 @@ a:hover,
|
|||||||
background: #00ff0e;
|
background: #00ff0e;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-reconnect {
|
#connection-error {
|
||||||
background: #f00;
|
background: #f00;
|
||||||
color: #fff;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#settings .opt {
|
#settings .opt {
|
||||||
|
@ -46,14 +46,6 @@ body {
|
|||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-reconnect {
|
|
||||||
background: #e74c3c;
|
|
||||||
color: #fff;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
#sidebar {
|
#sidebar {
|
||||||
left: -220px;
|
left: -220px;
|
||||||
|
@ -29,14 +29,6 @@ body {
|
|||||||
background: #333c4a;
|
background: #333c4a;
|
||||||
}
|
}
|
||||||
|
|
||||||
#windows .header .topic,
|
|
||||||
#windows #form .input,
|
|
||||||
.messages .msg,
|
|
||||||
.sidebar {
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#chat .count {
|
#chat .count {
|
||||||
background-color: #2e3642;
|
background-color: #2e3642;
|
||||||
}
|
}
|
||||||
@ -213,14 +205,6 @@ body {
|
|||||||
color: #99a2b4;
|
color: #99a2b4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-reconnect {
|
|
||||||
background: #e74c3c;
|
|
||||||
color: #fff;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form elements */
|
/* Form elements */
|
||||||
|
|
||||||
#chat-container ::-moz-placeholder {
|
#chat-container ::-moz-placeholder {
|
||||||
|
@ -30,14 +30,6 @@ body {
|
|||||||
background: #3f3f3f;
|
background: #3f3f3f;
|
||||||
}
|
}
|
||||||
|
|
||||||
#windows .header .topic,
|
|
||||||
#windows #form .input,
|
|
||||||
.messages .msg,
|
|
||||||
.sidebar {
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#settings,
|
#settings,
|
||||||
#sign-in,
|
#sign-in,
|
||||||
#connect .title {
|
#connect .title {
|
||||||
@ -240,14 +232,6 @@ body {
|
|||||||
color: #d2d39b;
|
color: #d2d39b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-reconnect {
|
|
||||||
background: #e74c3c;
|
|
||||||
color: #fff;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form elements */
|
/* Form elements */
|
||||||
|
|
||||||
#chat-container ::-moz-placeholder {
|
#chat-container ::-moz-placeholder {
|
||||||
|
3
client/views/actions/away.tpl
Normal file
3
client/views/actions/away.tpl
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{{> ../user_name nick=from}}
|
||||||
|
is away
|
||||||
|
<i class="away-message">({{{parse text}}})</i>
|
2
client/views/actions/back.tpl
Normal file
2
client/views/actions/back.tpl
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
{{> ../user_name nick=from}}
|
||||||
|
is back
|
@ -1,6 +1,6 @@
|
|||||||
{{> ../user_name nick=from}}
|
{{> ../user_name nick=from.nick mode=from.mode}}
|
||||||
has kicked
|
has kicked
|
||||||
{{> ../user_name nick=target mode=""}}
|
{{> ../user_name nick=target.nick mode=target.mode}}
|
||||||
{{#if text}}
|
{{#if text}}
|
||||||
<i class="part-reason">({{{parse text}}})</i>
|
<i class="part-reason">({{{parse text}}})</i>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<div class="date-marker-container tooltipped tooltipped-s" data-timestamp="{{msgDate}}" aria-label="{{localedate msgDate}}">
|
<div class="date-marker-container tooltipped tooltipped-s" data-time="{{time}}" aria-label="{{localedate time}}">
|
||||||
<div class="date-marker">
|
<div class="date-marker">
|
||||||
<span class="date-marker-text" data-label="{{friendlydate msgDate}}"></span>
|
<span class="date-marker-text" data-label="{{friendlydate time}}"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -3,6 +3,8 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
actions: {
|
actions: {
|
||||||
action: require("./actions/action.tpl"),
|
action: require("./actions/action.tpl"),
|
||||||
|
away: require("./actions/away.tpl"),
|
||||||
|
back: require("./actions/back.tpl"),
|
||||||
ban_list: require("./actions/ban_list.tpl"),
|
ban_list: require("./actions/ban_list.tpl"),
|
||||||
channel_list: require("./actions/channel_list.tpl"),
|
channel_list: require("./actions/channel_list.tpl"),
|
||||||
ctcp: require("./actions/ctcp.tpl"),
|
ctcp: require("./actions/ctcp.tpl"),
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
{{#preview}}
|
{{#preview}}
|
||||||
<button class="toggle-button {{#if shown}} opened{{/if}}"
|
<button class="toggle-button toggle-preview {{#if shown}} opened{{/if}}"
|
||||||
data-url="{{link}}"
|
data-url="{{link}}"
|
||||||
{{#equal type "image"}}
|
{{#equal type "image"}}
|
||||||
aria-label="Toggle image preview"
|
aria-label="Toggle image preview"
|
||||||
|
@ -51,11 +51,12 @@ module.exports = {
|
|||||||
|
|
||||||
//
|
//
|
||||||
// Set the default theme.
|
// Set the default theme.
|
||||||
|
// Find out how to add new themes at https://thelounge.github.io/docs/packages/themes
|
||||||
//
|
//
|
||||||
// @type string
|
// @type string
|
||||||
// @default "themes/example.css"
|
// @default "example"
|
||||||
//
|
//
|
||||||
theme: "themes/example.css",
|
theme: "example",
|
||||||
|
|
||||||
//
|
//
|
||||||
// Prefetch URLs
|
// Prefetch URLs
|
||||||
@ -89,12 +90,12 @@ module.exports = {
|
|||||||
// Prefetch URLs Image Preview size limit
|
// Prefetch URLs Image Preview size limit
|
||||||
//
|
//
|
||||||
// If prefetch is enabled, The Lounge will only display content under the maximum size.
|
// If prefetch is enabled, The Lounge will only display content under the maximum size.
|
||||||
// Specified value is in kilobytes. Default value is 512 kilobytes.
|
// Specified value is in kilobytes. Default value is 2048 kilobytes.
|
||||||
//
|
//
|
||||||
// @type int
|
// @type int
|
||||||
// @default 512
|
// @default 2048
|
||||||
//
|
//
|
||||||
prefetchMaxImageSize: 512,
|
prefetchMaxImageSize: 2048,
|
||||||
|
|
||||||
//
|
//
|
||||||
// Display network
|
// Display network
|
||||||
@ -365,6 +366,30 @@ module.exports = {
|
|||||||
// @type object
|
// @type object
|
||||||
// @default {}
|
// @default {}
|
||||||
//
|
//
|
||||||
|
// The authentication process works as follows:
|
||||||
|
//
|
||||||
|
// 1. Lounge connects to the LDAP server with its system credentials
|
||||||
|
// 2. It performs a LDAP search query to find the full DN associated to the
|
||||||
|
// user requesting to log in.
|
||||||
|
// 3. Lounge tries to connect a second time, but this time using the user's
|
||||||
|
// DN and password. Auth is validated iff this connection is successful.
|
||||||
|
//
|
||||||
|
// The search query takes a couple of parameters in `searchDN`:
|
||||||
|
// - a base DN `searchDN/base`. Only children nodes of this DN will be likely
|
||||||
|
// to be returned;
|
||||||
|
// - a search scope `searchDN/scope` (see LDAP documentation);
|
||||||
|
// - the query itself, build as (&(<primaryKey>=<username>) <filter>)
|
||||||
|
// where <username> is the user name provided in the log in request,
|
||||||
|
// <primaryKey> is provided by the config and <fitler> is a filtering complement
|
||||||
|
// also given in the config, to filter for instance only for nodes of type
|
||||||
|
// inetOrgPerson, or whatever LDAP search allows.
|
||||||
|
//
|
||||||
|
// Alternatively, you can specify the `bindDN` parameter. This will make the lounge
|
||||||
|
// ignore searchDN options and assume that the user DN is always:
|
||||||
|
// <bindDN>,<primaryKey>=<username>
|
||||||
|
// where <username> is the user name provided in the log in request, and <bindDN>
|
||||||
|
// and <primaryKey> are provided by the config.
|
||||||
|
//
|
||||||
ldap: {
|
ldap: {
|
||||||
//
|
//
|
||||||
// Enable LDAP user authentication
|
// Enable LDAP user authentication
|
||||||
@ -382,11 +407,25 @@ module.exports = {
|
|||||||
url: "ldaps://example.com",
|
url: "ldaps://example.com",
|
||||||
|
|
||||||
//
|
//
|
||||||
// LDAP base dn
|
// LDAP connection tls options (only used if scheme is ldaps://)
|
||||||
|
//
|
||||||
|
// @type object (see nodejs' tls.connect() options)
|
||||||
|
// @default {}
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// You can use this option in order to force the use of IPv6:
|
||||||
|
// {
|
||||||
|
// host: 'my::ip::v6',
|
||||||
|
// servername: 'example.com'
|
||||||
|
// }
|
||||||
|
tlsOptions: {},
|
||||||
|
|
||||||
|
//
|
||||||
|
// LDAP base dn, alternative to searchDN
|
||||||
//
|
//
|
||||||
// @type string
|
// @type string
|
||||||
//
|
//
|
||||||
baseDN: "ou=accounts,dc=example,dc=com",
|
// baseDN: "ou=accounts,dc=example,dc=com",
|
||||||
|
|
||||||
//
|
//
|
||||||
// LDAP primary key
|
// LDAP primary key
|
||||||
@ -394,7 +433,58 @@ module.exports = {
|
|||||||
// @type string
|
// @type string
|
||||||
// @default "uid"
|
// @default "uid"
|
||||||
//
|
//
|
||||||
primaryKey: "uid"
|
primaryKey: "uid",
|
||||||
|
|
||||||
|
//
|
||||||
|
// LDAP search dn settings. This defines the procedure by which the
|
||||||
|
// lounge first look for user DN before authenticating her.
|
||||||
|
// Ignored if baseDN is specified
|
||||||
|
//
|
||||||
|
// @type object
|
||||||
|
//
|
||||||
|
searchDN: {
|
||||||
|
|
||||||
|
//
|
||||||
|
// LDAP searching bind DN
|
||||||
|
// This bind DN is used to query the server for the DN of the user.
|
||||||
|
// This is supposed to be a system user that has access in read only to
|
||||||
|
// the DNs of the people that are allowed to log in.
|
||||||
|
//
|
||||||
|
// @type string
|
||||||
|
//
|
||||||
|
rootDN: "cn=thelounge,ou=system-users,dc=example,dc=com",
|
||||||
|
|
||||||
|
//
|
||||||
|
// Password of the lounge LDAP system user
|
||||||
|
//
|
||||||
|
// @type string
|
||||||
|
//
|
||||||
|
rootPassword: "1234",
|
||||||
|
|
||||||
|
//
|
||||||
|
// LDAP filter
|
||||||
|
//
|
||||||
|
// @type string
|
||||||
|
// @default "uid"
|
||||||
|
//
|
||||||
|
filter: "(objectClass=person)(memberOf=ou=accounts,dc=example,dc=com)",
|
||||||
|
|
||||||
|
//
|
||||||
|
// LDAP search base (search only within this node)
|
||||||
|
//
|
||||||
|
// @type string
|
||||||
|
//
|
||||||
|
base: "dc=example,dc=com",
|
||||||
|
|
||||||
|
//
|
||||||
|
// LDAP search scope
|
||||||
|
//
|
||||||
|
// @type string
|
||||||
|
// @default "sub"
|
||||||
|
//
|
||||||
|
scope: "sub"
|
||||||
|
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Extra debugging
|
// Extra debugging
|
||||||
|
2
index.js
2
index.js
@ -8,9 +8,11 @@ process.chdir(__dirname);
|
|||||||
// Doing this check as soon as possible allows us to avoid ES6 parser errors or other issues
|
// Doing this check as soon as possible allows us to avoid ES6 parser errors or other issues
|
||||||
var pkg = require("./package.json");
|
var pkg = require("./package.json");
|
||||||
if (!require("semver").satisfies(process.version, pkg.engines.node)) {
|
if (!require("semver").satisfies(process.version, pkg.engines.node)) {
|
||||||
|
/* eslint-disable no-console */
|
||||||
console.error("=== WARNING!");
|
console.error("=== WARNING!");
|
||||||
console.error("=== The oldest supported Node.js version is", pkg.engines.node);
|
console.error("=== The oldest supported Node.js version is", pkg.engines.node);
|
||||||
console.error("=== We strongly encourage you to upgrade, see https://nodejs.org/en/download/package-manager/ for more details\n");
|
console.error("=== We strongly encourage you to upgrade, see https://nodejs.org/en/download/package-manager/ for more details\n");
|
||||||
|
/* eslint-enable no-console */
|
||||||
}
|
}
|
||||||
|
|
||||||
require("./src/command-line");
|
require("./src/command-line");
|
||||||
|
17
package.json
17
package.json
@ -47,19 +47,20 @@
|
|||||||
"event-stream": "3.3.4",
|
"event-stream": "3.3.4",
|
||||||
"express": "4.15.4",
|
"express": "4.15.4",
|
||||||
"express-handlebars": "3.0.0",
|
"express-handlebars": "3.0.0",
|
||||||
"fs-extra": "4.0.1",
|
"fs-extra": "4.0.2",
|
||||||
"irc-framework": "2.9.1",
|
"irc-framework": "2.9.1",
|
||||||
"ldapjs": "1.0.1",
|
"ldapjs": "1.0.1",
|
||||||
"lodash": "4.17.4",
|
"lodash": "4.17.4",
|
||||||
"moment": "2.18.1",
|
"moment": "2.18.1",
|
||||||
|
"package-json": "4.0.1",
|
||||||
"read": "1.0.7",
|
"read": "1.0.7",
|
||||||
"request": "2.81.0",
|
"request": "2.82.0",
|
||||||
"semver": "5.4.1",
|
"semver": "5.4.1",
|
||||||
"socket.io": "1.7.4",
|
"socket.io": "1.7.4",
|
||||||
"spdy": "3.4.7",
|
"spdy": "3.4.7",
|
||||||
"ua-parser-js": "0.7.14",
|
"ua-parser-js": "0.7.14",
|
||||||
"urijs": "1.18.12",
|
"urijs": "1.18.12",
|
||||||
"web-push": "3.2.2"
|
"web-push": "3.2.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"babel-core": "6.26.0",
|
"babel-core": "6.26.0",
|
||||||
@ -68,7 +69,7 @@
|
|||||||
"chai": "4.1.2",
|
"chai": "4.1.2",
|
||||||
"css.escape": "1.5.1",
|
"css.escape": "1.5.1",
|
||||||
"emoji-regex": "6.5.1",
|
"emoji-regex": "6.5.1",
|
||||||
"eslint": "4.5.0",
|
"eslint": "4.7.2",
|
||||||
"font-awesome": "4.7.0",
|
"font-awesome": "4.7.0",
|
||||||
"fuzzy": "0.1.3",
|
"fuzzy": "0.1.3",
|
||||||
"handlebars": "4.0.10",
|
"handlebars": "4.0.10",
|
||||||
@ -77,13 +78,13 @@
|
|||||||
"jquery": "3.2.1",
|
"jquery": "3.2.1",
|
||||||
"jquery-textcomplete": "1.8.4",
|
"jquery-textcomplete": "1.8.4",
|
||||||
"jquery-ui": "1.12.1",
|
"jquery-ui": "1.12.1",
|
||||||
"mocha": "3.5.0",
|
"mocha": "3.5.3",
|
||||||
"mousetrap": "1.6.1",
|
"mousetrap": "1.6.1",
|
||||||
"npm-run-all": "4.1.1",
|
"npm-run-all": "4.1.1",
|
||||||
"nyc": "11.1.0",
|
"nyc": "11.2.1",
|
||||||
"socket.io-client": "1.7.4",
|
"socket.io-client": "1.7.4",
|
||||||
"stylelint": "8.0.0",
|
"stylelint": "8.1.1",
|
||||||
"stylelint-config-standard": "17.0.0",
|
"stylelint-config-standard": "17.0.0",
|
||||||
"webpack": "3.5.5"
|
"webpack": "3.6.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,25 +1,27 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
var fs = require("fs-extra");
|
const colors = require("colors/safe");
|
||||||
|
const fs = require("fs-extra");
|
||||||
|
const log = require("../src/log");
|
||||||
|
|
||||||
var srcDir = "./node_modules/font-awesome/fonts/";
|
const srcDir = "./node_modules/font-awesome/fonts/";
|
||||||
var destDir = "./client/fonts/";
|
const destDir = "./client/fonts/";
|
||||||
var fonts = [
|
const fonts = [
|
||||||
"fontawesome-webfont.woff",
|
"fontawesome-webfont.woff",
|
||||||
"fontawesome-webfont.woff2"
|
"fontawesome-webfont.woff2"
|
||||||
];
|
];
|
||||||
|
|
||||||
fs.ensureDir(destDir, function(dirErr) {
|
fs.ensureDir(destDir, (dirErr) => {
|
||||||
if (dirErr) {
|
if (dirErr) {
|
||||||
console.error(dirErr);
|
log.error(dirErr);
|
||||||
}
|
}
|
||||||
|
|
||||||
fonts.forEach(function(font) {
|
fonts.forEach((font) => {
|
||||||
fs.copy(srcDir + font, destDir + font, function(err) {
|
fs.copy(srcDir + font, destDir + font, (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error(err);
|
log.error(err);
|
||||||
} else {
|
} else {
|
||||||
console.log(font + " successfully installed.");
|
log.info(colors.bold(font) + " successfully installed.");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -5,7 +5,6 @@ var colors = require("colors/safe");
|
|||||||
var pkg = require("../package.json");
|
var pkg = require("../package.json");
|
||||||
var Chan = require("./models/chan");
|
var Chan = require("./models/chan");
|
||||||
var crypto = require("crypto");
|
var crypto = require("crypto");
|
||||||
var userLog = require("./userLog");
|
|
||||||
var Msg = require("./models/msg");
|
var Msg = require("./models/msg");
|
||||||
var Network = require("./models/network");
|
var Network = require("./models/network");
|
||||||
var ircFramework = require("irc-framework");
|
var ircFramework = require("irc-framework");
|
||||||
@ -16,6 +15,7 @@ module.exports = Client;
|
|||||||
|
|
||||||
var id = 0;
|
var id = 0;
|
||||||
var events = [
|
var events = [
|
||||||
|
"away",
|
||||||
"connection",
|
"connection",
|
||||||
"unhandled",
|
"unhandled",
|
||||||
"banlist",
|
"banlist",
|
||||||
@ -114,23 +114,6 @@ Client.prototype.emit = function(event, data) {
|
|||||||
if (this.sockets !== null) {
|
if (this.sockets !== null) {
|
||||||
this.sockets.in(this.id).emit(event, data);
|
this.sockets.in(this.id).emit(event, data);
|
||||||
}
|
}
|
||||||
if (this.config.log === true) {
|
|
||||||
if (event === "msg") {
|
|
||||||
var target = this.find(data.chan);
|
|
||||||
if (target) {
|
|
||||||
var chan = target.chan.name;
|
|
||||||
if (target.chan.type === Chan.Type.LOBBY) {
|
|
||||||
chan = target.network.host;
|
|
||||||
}
|
|
||||||
userLog.write(
|
|
||||||
this.name,
|
|
||||||
target.network.host,
|
|
||||||
chan,
|
|
||||||
data.msg
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Client.prototype.find = function(channelId) {
|
Client.prototype.find = function(channelId) {
|
||||||
@ -412,10 +395,20 @@ Client.prototype.more = function(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const chan = target.chan;
|
const chan = target.chan;
|
||||||
const index = chan.messages.findIndex((val) => val.id === data.lastId);
|
let messages = [];
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
// If we don't find the requested message, send an empty array
|
// If client requests -1, send last 100 messages
|
||||||
const messages = index > 0 ? chan.messages.slice(Math.max(0, index - 100), index) : [];
|
if (data.lastId < 0) {
|
||||||
|
index = chan.messages.length;
|
||||||
|
} else {
|
||||||
|
index = chan.messages.findIndex((val) => val.id === data.lastId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If requested id is not found, an empty array will be sent
|
||||||
|
if (index > 0) {
|
||||||
|
messages = chan.messages.slice(Math.max(0, index - 100), index);
|
||||||
|
}
|
||||||
|
|
||||||
client.emit("more", {
|
client.emit("more", {
|
||||||
chan: chan.id,
|
chan: chan.id,
|
||||||
@ -437,7 +430,7 @@ Client.prototype.open = function(socketId, target) {
|
|||||||
|
|
||||||
target.chan.firstUnread = 0;
|
target.chan.firstUnread = 0;
|
||||||
target.chan.unread = 0;
|
target.chan.unread = 0;
|
||||||
target.chan.highlight = false;
|
target.chan.highlight = 0;
|
||||||
|
|
||||||
this.attachedClients[socketId].openChannel = target.chan.id;
|
this.attachedClients[socketId].openChannel = target.chan.id;
|
||||||
this.lastActiveChannel = target.chan.id;
|
this.lastActiveChannel = target.chan.id;
|
||||||
|
@ -32,6 +32,7 @@ require("./add");
|
|||||||
require("./remove");
|
require("./remove");
|
||||||
require("./reset");
|
require("./reset");
|
||||||
require("./edit");
|
require("./edit");
|
||||||
|
require("./install");
|
||||||
|
|
||||||
program.parse(process.argv);
|
program.parse(process.argv);
|
||||||
|
|
||||||
|
82
src/command-line/install.js
Normal file
82
src/command-line/install.js
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const colors = require("colors/safe");
|
||||||
|
const program = require("commander");
|
||||||
|
const Helper = require("../helper");
|
||||||
|
const Utils = require("./utils");
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("install <package>")
|
||||||
|
.description("Install a theme or a package")
|
||||||
|
.on("--help", Utils.extraHelp)
|
||||||
|
.action(function(packageName) {
|
||||||
|
const fs = require("fs");
|
||||||
|
const fsextra = require("fs-extra");
|
||||||
|
const path = require("path");
|
||||||
|
const child = require("child_process");
|
||||||
|
const packageJson = require("package-json");
|
||||||
|
|
||||||
|
if (!fs.existsSync(Helper.CONFIG_PATH)) {
|
||||||
|
log.error(`${Helper.CONFIG_PATH} does not exist.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
packageJson(packageName, {
|
||||||
|
fullMetadata: true
|
||||||
|
}).then((json) => {
|
||||||
|
if (!("lounge" in json)) {
|
||||||
|
log.error(`${colors.red(packageName)} does not have The Lounge metadata.`);
|
||||||
|
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(`Installing ${colors.green(packageName)}...`);
|
||||||
|
|
||||||
|
const packagesPath = Helper.getPackagesPath();
|
||||||
|
const packagesParent = path.dirname(packagesPath);
|
||||||
|
const packagesConfig = path.join(packagesParent, "package.json");
|
||||||
|
|
||||||
|
// Create node_modules folder, otherwise npm will start walking upwards to find one
|
||||||
|
fsextra.ensureDirSync(packagesPath);
|
||||||
|
|
||||||
|
// Create package.json with private set to true to avoid npm warnings
|
||||||
|
fs.writeFileSync(packagesConfig, JSON.stringify({
|
||||||
|
private: true,
|
||||||
|
description: "Packages for The Lounge. All packages in node_modules directory will be automatically loaded.",
|
||||||
|
}, null, "\t"));
|
||||||
|
|
||||||
|
const npm = child.spawn(
|
||||||
|
process.platform === "win32" ? "npm.cmd" : "npm",
|
||||||
|
[
|
||||||
|
"install",
|
||||||
|
"--production",
|
||||||
|
"--no-save",
|
||||||
|
"--no-bin-links",
|
||||||
|
"--no-package-lock",
|
||||||
|
"--prefix",
|
||||||
|
packagesParent,
|
||||||
|
packageName
|
||||||
|
],
|
||||||
|
{
|
||||||
|
stdio: "inherit"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
npm.on("error", (e) => {
|
||||||
|
log.error(`${e}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
npm.on("close", (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
log.error(`Failed to install ${colors.green(packageName)}. Exit code: ${code}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(`${colors.green(packageName)} has been successfully installed.`);
|
||||||
|
});
|
||||||
|
}).catch((e) => {
|
||||||
|
log.error(`${e}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
});
|
@ -15,7 +15,7 @@ class Utils {
|
|||||||
"",
|
"",
|
||||||
` LOUNGE_HOME Path for all configuration files and folders. Defaults to ${colors.green(Utils.defaultLoungeHome())}.`,
|
` LOUNGE_HOME Path for all configuration files and folders. Defaults to ${colors.green(Utils.defaultLoungeHome())}.`,
|
||||||
"",
|
"",
|
||||||
].forEach((e) => console.log(e));
|
].forEach((e) => console.log(e)); // eslint-disable-line no-console
|
||||||
}
|
}
|
||||||
|
|
||||||
static defaultLoungeHome() {
|
static defaultLoungeHome() {
|
||||||
|
@ -12,6 +12,8 @@ const colors = require("colors/safe");
|
|||||||
var Helper = {
|
var Helper = {
|
||||||
config: null,
|
config: null,
|
||||||
expandHome: expandHome,
|
expandHome: expandHome,
|
||||||
|
getPackagesPath: getPackagesPath,
|
||||||
|
getPackageModulePath: getPackageModulePath,
|
||||||
getStoragePath: getStoragePath,
|
getStoragePath: getStoragePath,
|
||||||
getUserConfigPath: getUserConfigPath,
|
getUserConfigPath: getUserConfigPath,
|
||||||
getUserLogsPath: getUserLogsPath,
|
getUserLogsPath: getUserLogsPath,
|
||||||
@ -82,6 +84,14 @@ function setHome(homePath) {
|
|||||||
log.warn("debug option is now an object, see defaults file for more information.");
|
log.warn("debug option is now an object, see defaults file for more information.");
|
||||||
this.config.debug = {ircFramework: true};
|
this.config.debug = {ircFramework: true};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Remove in future release
|
||||||
|
// Backwards compatibility for old way of specifying themes in settings
|
||||||
|
if (this.config.theme.includes(".css")) {
|
||||||
|
log.warn(`Referring to CSS files in the ${colors.green("theme")} setting of ${colors.green(Helper.CONFIG_PATH)} is ${colors.bold("deprecated")} and will be removed in a future version.`);
|
||||||
|
} else {
|
||||||
|
this.config.theme = `themes/${this.config.theme}.css`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUserConfigPath(name) {
|
function getUserConfigPath(name) {
|
||||||
@ -96,6 +106,14 @@ function getStoragePath() {
|
|||||||
return path.join(this.HOME, "storage");
|
return path.join(this.HOME, "storage");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPackagesPath() {
|
||||||
|
return path.join(this.HOME, "packages", "node_modules");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPackageModulePath(packageName) {
|
||||||
|
return path.join(Helper.getPackagesPath(), packageName);
|
||||||
|
}
|
||||||
|
|
||||||
function ip2hex(address) {
|
function ip2hex(address) {
|
||||||
// no ipv6 support
|
// no ipv6 support
|
||||||
if (!net.isIPv4(address)) {
|
if (!net.isIPv4(address)) {
|
||||||
|
@ -16,6 +16,7 @@ function timestamp(type, messageArgs) {
|
|||||||
return messageArgs;
|
return messageArgs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* eslint-disable no-console */
|
||||||
exports.error = function() {
|
exports.error = function() {
|
||||||
console.error.apply(console, timestamp(colors.red("[ERROR]"), arguments));
|
console.error.apply(console, timestamp(colors.red("[ERROR]"), arguments));
|
||||||
};
|
};
|
||||||
@ -31,6 +32,7 @@ exports.info = function() {
|
|||||||
exports.debug = function() {
|
exports.debug = function() {
|
||||||
console.log.apply(console, timestamp(colors.green("[DEBUG]"), arguments));
|
console.log.apply(console, timestamp(colors.green("[DEBUG]"), arguments));
|
||||||
};
|
};
|
||||||
|
/* eslint-enable no-console */
|
||||||
|
|
||||||
exports.prompt = (options, callback) => {
|
exports.prompt = (options, callback) => {
|
||||||
options.prompt = timestamp(colors.cyan("[PROMPT]"), [options.text]).join(" ");
|
options.prompt = timestamp(colors.cyan("[PROMPT]"), [options.text]).join(" ");
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
var _ = require("lodash");
|
var _ = require("lodash");
|
||||||
var Helper = require("../helper");
|
var Helper = require("../helper");
|
||||||
|
const userLog = require("../userLog");
|
||||||
const storage = require("../plugins/storage");
|
const storage = require("../plugins/storage");
|
||||||
|
|
||||||
module.exports = Chan;
|
module.exports = Chan;
|
||||||
@ -13,7 +14,7 @@ Chan.Type = {
|
|||||||
SPECIAL: "special",
|
SPECIAL: "special",
|
||||||
};
|
};
|
||||||
|
|
||||||
var id = 0;
|
let id = 1;
|
||||||
|
|
||||||
function Chan(attr) {
|
function Chan(attr) {
|
||||||
_.defaults(this, attr, {
|
_.defaults(this, attr, {
|
||||||
@ -25,7 +26,7 @@ function Chan(attr) {
|
|||||||
type: Chan.Type.CHANNEL,
|
type: Chan.Type.CHANNEL,
|
||||||
firstUnread: 0,
|
firstUnread: 0,
|
||||||
unread: 0,
|
unread: 0,
|
||||||
highlight: false,
|
highlight: 0,
|
||||||
users: []
|
users: []
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -57,6 +58,10 @@ Chan.prototype.pushMessage = function(client, msg, increasesUnread) {
|
|||||||
|
|
||||||
this.messages.push(msg);
|
this.messages.push(msg);
|
||||||
|
|
||||||
|
if (client.config.log === true) {
|
||||||
|
writeUserLog.call(this, client, msg);
|
||||||
|
}
|
||||||
|
|
||||||
if (Helper.config.maxHistory >= 0 && this.messages.length > Helper.config.maxHistory) {
|
if (Helper.config.maxHistory >= 0 && this.messages.length > Helper.config.maxHistory) {
|
||||||
const deleted = this.messages.splice(0, this.messages.length - Helper.config.maxHistory);
|
const deleted = this.messages.splice(0, this.messages.length - Helper.config.maxHistory);
|
||||||
|
|
||||||
@ -73,7 +78,7 @@ Chan.prototype.pushMessage = function(client, msg, increasesUnread) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (msg.highlight) {
|
if (msg.highlight) {
|
||||||
this.highlight = true;
|
this.highlight++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -127,3 +132,14 @@ Chan.prototype.toJSON = function() {
|
|||||||
clone.messages = clone.messages.slice(-100);
|
clone.messages = clone.messages.slice(-100);
|
||||||
return clone;
|
return clone;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function writeUserLog(client, msg) {
|
||||||
|
const target = client.find(this.id);
|
||||||
|
|
||||||
|
userLog.write(
|
||||||
|
client.name,
|
||||||
|
target.network.host, // TODO: Fix #1392, multiple connections to same server results in duplicate logs
|
||||||
|
this.type === Chan.Type.LOBBY ? target.network.host : this.name,
|
||||||
|
msg
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -29,7 +29,9 @@ class Msg {
|
|||||||
|
|
||||||
Msg.Type = {
|
Msg.Type = {
|
||||||
UNHANDLED: "unhandled",
|
UNHANDLED: "unhandled",
|
||||||
|
AWAY: "away",
|
||||||
ACTION: "action",
|
ACTION: "action",
|
||||||
|
BACK: "back",
|
||||||
ERROR: "error",
|
ERROR: "error",
|
||||||
INVITE: "invite",
|
INVITE: "invite",
|
||||||
JOIN: "join",
|
JOIN: "join",
|
||||||
|
@ -5,7 +5,7 @@ var Chan = require("./chan");
|
|||||||
|
|
||||||
module.exports = Network;
|
module.exports = Network;
|
||||||
|
|
||||||
var id = 0;
|
let id = 1;
|
||||||
|
|
||||||
function Network(attr) {
|
function Network(attr) {
|
||||||
_.defaults(this, attr, {
|
_.defaults(this, attr, {
|
||||||
|
@ -7,6 +7,7 @@ module.exports = User;
|
|||||||
function User(attr, prefixLookup) {
|
function User(attr, prefixLookup) {
|
||||||
_.defaults(this, attr, {
|
_.defaults(this, attr, {
|
||||||
modes: [],
|
modes: [],
|
||||||
|
away: "",
|
||||||
mode: "",
|
mode: "",
|
||||||
nick: "",
|
nick: "",
|
||||||
lastMessage: 0,
|
lastMessage: 0,
|
||||||
|
132
src/plugins/auth/ldap.js
Normal file
132
src/plugins/auth/ldap.js
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const Helper = require("../../helper");
|
||||||
|
const ldap = require("ldapjs");
|
||||||
|
|
||||||
|
function ldapAuthCommon(user, bindDN, password, callback) {
|
||||||
|
const config = Helper.config;
|
||||||
|
|
||||||
|
const ldapclient = ldap.createClient({
|
||||||
|
url: config.ldap.url,
|
||||||
|
tlsOptions: config.ldap.tlsOptions
|
||||||
|
});
|
||||||
|
|
||||||
|
ldapclient.on("error", function(err) {
|
||||||
|
log.error(`Unable to connect to LDAP server: ${err}`);
|
||||||
|
callback(!err);
|
||||||
|
});
|
||||||
|
|
||||||
|
ldapclient.bind(bindDN, password, function(err) {
|
||||||
|
ldapclient.unbind();
|
||||||
|
callback(!err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function simpleLdapAuth(user, password, callback) {
|
||||||
|
if (!user) {
|
||||||
|
return callback(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = Helper.config;
|
||||||
|
|
||||||
|
const userDN = user.replace(/([,\\/#+<>;"= ])/g, "\\$1");
|
||||||
|
const bindDN = `${config.ldap.primaryKey}=${userDN},${config.ldap.baseDN}`;
|
||||||
|
|
||||||
|
log.info(`Auth against LDAP ${config.ldap.url} with provided bindDN ${bindDN}`);
|
||||||
|
|
||||||
|
ldapAuthCommon(user, bindDN, password, callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LDAP auth using initial DN search (see config comment for ldap.searchDN)
|
||||||
|
*/
|
||||||
|
function advancedLdapAuth(user, password, callback) {
|
||||||
|
if (!user) {
|
||||||
|
return callback(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = Helper.config;
|
||||||
|
const userDN = user.replace(/([,\\/#+<>;"= ])/g, "\\$1");
|
||||||
|
|
||||||
|
const ldapclient = ldap.createClient({
|
||||||
|
url: config.ldap.url,
|
||||||
|
tlsOptions: config.ldap.tlsOptions
|
||||||
|
});
|
||||||
|
|
||||||
|
const base = config.ldap.searchDN.base;
|
||||||
|
const searchOptions = {
|
||||||
|
scope: config.ldap.searchDN.scope,
|
||||||
|
filter: `(&(${config.ldap.primaryKey}=${userDN})${config.ldap.searchDN.filter})`,
|
||||||
|
attributes: ["dn"]
|
||||||
|
};
|
||||||
|
|
||||||
|
ldapclient.on("error", function(err) {
|
||||||
|
log.error(`Unable to connect to LDAP server: ${err}`);
|
||||||
|
callback(!err);
|
||||||
|
});
|
||||||
|
|
||||||
|
ldapclient.bind(config.ldap.searchDN.rootDN, config.ldap.searchDN.rootPassword, function(err) {
|
||||||
|
if (err) {
|
||||||
|
log.error("Invalid LDAP root credentials");
|
||||||
|
ldapclient.unbind();
|
||||||
|
callback(false);
|
||||||
|
} else {
|
||||||
|
ldapclient.search(base, searchOptions, function(err2, res) {
|
||||||
|
if (err2) {
|
||||||
|
log.warning(`User not found: ${userDN}`);
|
||||||
|
ldapclient.unbind();
|
||||||
|
callback(false);
|
||||||
|
} else {
|
||||||
|
let found = false;
|
||||||
|
res.on("searchEntry", function(entry) {
|
||||||
|
found = true;
|
||||||
|
const bindDN = entry.objectName;
|
||||||
|
log.info(`Auth against LDAP ${config.ldap.url} with found bindDN ${bindDN}`);
|
||||||
|
ldapclient.unbind();
|
||||||
|
|
||||||
|
ldapAuthCommon(user, bindDN, password, callback);
|
||||||
|
});
|
||||||
|
res.on("error", function(err3) {
|
||||||
|
log.error(`LDAP error: ${err3}`);
|
||||||
|
callback(false);
|
||||||
|
});
|
||||||
|
res.on("end", function() {
|
||||||
|
if (!found) {
|
||||||
|
callback(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ldapAuth(manager, client, user, password, callback) {
|
||||||
|
// TODO: Enable the use of starttls() as an alternative to ldaps
|
||||||
|
|
||||||
|
// TODO: move this out of here and get rid of `manager` and `client` in
|
||||||
|
// auth plugin API
|
||||||
|
function callbackWrapper(valid) {
|
||||||
|
if (valid && !client) {
|
||||||
|
manager.addUser(user, null);
|
||||||
|
}
|
||||||
|
callback(valid);
|
||||||
|
}
|
||||||
|
|
||||||
|
let auth;
|
||||||
|
if ("baseDN" in Helper.config.ldap) {
|
||||||
|
auth = simpleLdapAuth;
|
||||||
|
} else {
|
||||||
|
auth = advancedLdapAuth;
|
||||||
|
}
|
||||||
|
return auth(user, password, callbackWrapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLdapEnabled() {
|
||||||
|
return !Helper.config.public && Helper.config.ldap.enable;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
auth: ldapAuth,
|
||||||
|
isEnabled: isLdapEnabled
|
||||||
|
};
|
42
src/plugins/auth/local.js
Normal file
42
src/plugins/auth/local.js
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const Helper = require("../../helper");
|
||||||
|
const colors = require("colors/safe");
|
||||||
|
|
||||||
|
function localAuth(manager, client, user, password, callback) {
|
||||||
|
// If no user is found, or if the client has not provided a password,
|
||||||
|
// fail the authentication straight away
|
||||||
|
if (!client || !password) {
|
||||||
|
return callback(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this user has no password set, fail the authentication
|
||||||
|
if (!client.config.password) {
|
||||||
|
log.error(`User ${colors.bold(user)} with no local password set tried to sign in. (Probably a LDAP user)`);
|
||||||
|
return callback(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Helper.password
|
||||||
|
.compare(password, client.config.password)
|
||||||
|
.then((matching) => {
|
||||||
|
if (matching && Helper.password.requiresUpdate(client.config.password)) {
|
||||||
|
const hash = Helper.password.hash(password);
|
||||||
|
|
||||||
|
client.setPassword(hash, (success) => {
|
||||||
|
if (success) {
|
||||||
|
log.info(`User ${colors.bold(client.name)} logged in and their hashed password has been updated to match new security requirements`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(matching);
|
||||||
|
}).catch((error) => {
|
||||||
|
log.error(`Error while checking users password. Error: ${error}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
auth: localAuth,
|
||||||
|
isEnabled: () => true
|
||||||
|
};
|
||||||
|
|
@ -9,7 +9,17 @@ exports.commands = ["close", "leave", "part"];
|
|||||||
exports.allowDisconnected = true;
|
exports.allowDisconnected = true;
|
||||||
|
|
||||||
exports.input = function(network, chan, cmd, args) {
|
exports.input = function(network, chan, cmd, args) {
|
||||||
if (chan.type === Chan.Type.LOBBY) {
|
let target = args.length === 0 ? chan : _.find(network.channels, {name: args[0]});
|
||||||
|
let partMessage = args.length <= 1 ? Helper.config.leaveMessage : args.slice(1).join(" ");
|
||||||
|
|
||||||
|
if (typeof target === "undefined") {
|
||||||
|
// In this case, we assume that the word args[0] is part of the leave
|
||||||
|
// message and we part the current chan.
|
||||||
|
target = chan;
|
||||||
|
partMessage = args.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.type === Chan.Type.LOBBY) {
|
||||||
chan.pushMessage(this, new Msg({
|
chan.pushMessage(this, new Msg({
|
||||||
type: Msg.Type.ERROR,
|
type: Msg.Type.ERROR,
|
||||||
text: "You can not part from networks, use /quit instead."
|
text: "You can not part from networks, use /quit instead."
|
||||||
@ -17,18 +27,17 @@ exports.input = function(network, chan, cmd, args) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
network.channels = _.without(network.channels, chan);
|
network.channels = _.without(network.channels, target);
|
||||||
chan.destroy();
|
target.destroy();
|
||||||
this.emit("part", {
|
this.emit("part", {
|
||||||
chan: chan.id
|
chan: target.id
|
||||||
});
|
});
|
||||||
|
|
||||||
if (chan.type === Chan.Type.CHANNEL) {
|
if (target.type === Chan.Type.CHANNEL) {
|
||||||
this.save();
|
this.save();
|
||||||
|
|
||||||
if (network.irc) {
|
if (network.irc) {
|
||||||
const partMessage = args[0] ? args.join(" ") : Helper.config.leaveMessage;
|
network.irc.part(target.name, partMessage);
|
||||||
network.irc.part(chan.name, partMessage);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
30
src/plugins/irc-events/away.js
Normal file
30
src/plugins/irc-events/away.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const _ = require("lodash");
|
||||||
|
const Msg = require("../../models/msg");
|
||||||
|
|
||||||
|
module.exports = function(irc, network) {
|
||||||
|
const client = this;
|
||||||
|
irc.on("away", (data) => {
|
||||||
|
const away = data.message;
|
||||||
|
|
||||||
|
network.channels.forEach((chan) => {
|
||||||
|
const user = _.find(chan.users, {nick: data.nick});
|
||||||
|
|
||||||
|
if (!user || user.away === away) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = new Msg({
|
||||||
|
type: away ? Msg.Type.AWAY : Msg.Type.BACK,
|
||||||
|
text: away || "",
|
||||||
|
time: data.time,
|
||||||
|
from: data.nick,
|
||||||
|
mode: user.mode
|
||||||
|
});
|
||||||
|
|
||||||
|
chan.pushMessage(client, msg);
|
||||||
|
user.away = away;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
@ -11,12 +11,13 @@ module.exports = function(irc, network) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = chan.findUser(data.kicked);
|
const kicker = chan.findUser(data.nick);
|
||||||
|
const target = chan.findUser(data.kicked);
|
||||||
|
|
||||||
if (data.kicked === irc.user.nick) {
|
if (data.kicked === irc.user.nick) {
|
||||||
chan.users = [];
|
chan.users = [];
|
||||||
} else {
|
} else {
|
||||||
chan.users = _.without(chan.users, user);
|
chan.users = _.without(chan.users, target);
|
||||||
}
|
}
|
||||||
|
|
||||||
client.emit("users", {
|
client.emit("users", {
|
||||||
@ -26,9 +27,8 @@ module.exports = function(irc, network) {
|
|||||||
var msg = new Msg({
|
var msg = new Msg({
|
||||||
type: Msg.Type.KICK,
|
type: Msg.Type.KICK,
|
||||||
time: data.time,
|
time: data.time,
|
||||||
mode: user.mode,
|
from: kicker,
|
||||||
from: data.nick,
|
target: target,
|
||||||
target: data.kicked,
|
|
||||||
text: data.message || "",
|
text: data.message || "",
|
||||||
highlight: data.kicked === irc.user.nick,
|
highlight: data.kicked === irc.user.nick,
|
||||||
self: data.nick === irc.user.nick
|
self: data.nick === irc.user.nick
|
||||||
|
@ -106,20 +106,29 @@ module.exports = function(irc, network) {
|
|||||||
|
|
||||||
// Do not send notifications for messages older than 15 minutes (znc buffer for example)
|
// Do not send notifications for messages older than 15 minutes (znc buffer for example)
|
||||||
if (highlight && (!data.time || data.time > Date.now() - 900000)) {
|
if (highlight && (!data.time || data.time > Date.now() - 900000)) {
|
||||||
let title = data.nick;
|
let title = chan.name;
|
||||||
|
let body = Helper.cleanIrcMessage(data.message);
|
||||||
|
|
||||||
|
// In channels, prepend sender nickname to the message
|
||||||
if (chan.type !== Chan.Type.QUERY) {
|
if (chan.type !== Chan.Type.QUERY) {
|
||||||
title += ` (${chan.name}) mentioned you`;
|
body = `${data.nick}: ${body}`;
|
||||||
} else {
|
}
|
||||||
title += " sent you a message";
|
|
||||||
|
// If a channel is active on any client, highlight won't increment and notification will say (0 mention)
|
||||||
|
if (chan.highlight > 0) {
|
||||||
|
title += ` (${chan.highlight} ${chan.type === Chan.Type.QUERY ? "new message" : "mention"}${chan.highlight > 1 ? "s" : ""})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chan.highlight > 1) {
|
||||||
|
body += `\n\n… and ${chan.highlight - 1} other message${chan.highlight > 2 ? "s" : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
client.manager.webPush.push(client, {
|
client.manager.webPush.push(client, {
|
||||||
type: "notification",
|
type: "notification",
|
||||||
chanId: chan.id,
|
chanId: chan.id,
|
||||||
timestamp: data.time || Date.now(),
|
timestamp: data.time || Date.now(),
|
||||||
title: `The Lounge: ${title}`,
|
title: title,
|
||||||
body: Helper.cleanIrcMessage(data.message)
|
body: body
|
||||||
}, true);
|
}, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
85
src/plugins/themes.js
Normal file
85
src/plugins/themes.js
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const Helper = require("../helper");
|
||||||
|
const colors = require("colors/safe");
|
||||||
|
const path = require("path");
|
||||||
|
const _ = require("lodash");
|
||||||
|
const themes = new Map();
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getAll: getAll,
|
||||||
|
getFilename: getFilename
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.readdir("client/themes/", (err, builtInThemes) => {
|
||||||
|
if (err) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
builtInThemes
|
||||||
|
.filter((theme) => theme.endsWith(".css"))
|
||||||
|
.map(makeLocalThemeObject)
|
||||||
|
.forEach((theme) => themes.set(theme.name, theme));
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.readdir(Helper.getPackagesPath(), (err, packages) => {
|
||||||
|
if (err) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
packages
|
||||||
|
.map(makePackageThemeObject)
|
||||||
|
.forEach((theme) => {
|
||||||
|
if (theme) {
|
||||||
|
themes.set(theme.name, theme);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function getAll() {
|
||||||
|
return _.sortBy(Array.from(themes.values()), "displayName");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFilename(module) {
|
||||||
|
if (themes.has(module)) {
|
||||||
|
return themes.get(module).filename;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeLocalThemeObject(css) {
|
||||||
|
const themeName = css.slice(0, -4);
|
||||||
|
return {
|
||||||
|
displayName: themeName.charAt(0).toUpperCase() + themeName.slice(1),
|
||||||
|
filename: path.join(__dirname, "..", "client", "themes", `${themeName}.css`),
|
||||||
|
name: themeName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModuleInfo(packageName) {
|
||||||
|
let module;
|
||||||
|
try {
|
||||||
|
module = require(Helper.getPackageModulePath(packageName));
|
||||||
|
} catch (e) {
|
||||||
|
log.warn(`Specified theme ${colors.yellow(packageName)} is not installed in packages directory`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!module.lounge) {
|
||||||
|
log.warn(`Specified theme ${colors.yellow(packageName)} doesn't have required information.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return module.lounge;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePackageThemeObject(moduleName) {
|
||||||
|
const module = getModuleInfo(moduleName);
|
||||||
|
if (!module || module.type !== "theme") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const modulePath = Helper.getPackageModulePath(moduleName);
|
||||||
|
const displayName = module.name || moduleName;
|
||||||
|
const filename = path.join(modulePath, module.css);
|
||||||
|
return {
|
||||||
|
displayName: displayName,
|
||||||
|
filename: filename,
|
||||||
|
name: moduleName
|
||||||
|
};
|
||||||
|
}
|
126
src/server.js
126
src/server.js
@ -11,10 +11,20 @@ var path = require("path");
|
|||||||
var io = require("socket.io");
|
var io = require("socket.io");
|
||||||
var dns = require("dns");
|
var dns = require("dns");
|
||||||
var Helper = require("./helper");
|
var Helper = require("./helper");
|
||||||
var ldap = require("ldapjs");
|
|
||||||
var colors = require("colors/safe");
|
var colors = require("colors/safe");
|
||||||
const net = require("net");
|
const net = require("net");
|
||||||
const Identification = require("./identification");
|
const Identification = require("./identification");
|
||||||
|
const themes = require("./plugins/themes");
|
||||||
|
|
||||||
|
// The order defined the priority: the first available plugin is used
|
||||||
|
// ALways keep local auth in the end, which should always be enabled.
|
||||||
|
const authPlugins = [
|
||||||
|
require("./plugins/auth/ldap"),
|
||||||
|
require("./plugins/auth/local"),
|
||||||
|
];
|
||||||
|
|
||||||
|
// A random number that will force clients to reload the page if it differs
|
||||||
|
const serverHash = Math.floor(Date.now() * Math.random());
|
||||||
|
|
||||||
var manager = null;
|
var manager = null;
|
||||||
|
|
||||||
@ -45,6 +55,15 @@ module.exports = function() {
|
|||||||
.set("view engine", "html")
|
.set("view engine", "html")
|
||||||
.set("views", path.join(__dirname, "..", "client"));
|
.set("views", path.join(__dirname, "..", "client"));
|
||||||
|
|
||||||
|
app.get("/themes/:theme.css", (req, res) => {
|
||||||
|
const themeName = req.params.theme;
|
||||||
|
const theme = themes.getFilename(themeName);
|
||||||
|
if (theme === undefined) {
|
||||||
|
return res.status(404).send("Not found");
|
||||||
|
}
|
||||||
|
return res.sendFile(theme);
|
||||||
|
});
|
||||||
|
|
||||||
var config = Helper.config;
|
var config = Helper.config;
|
||||||
var server = null;
|
var server = null;
|
||||||
|
|
||||||
@ -94,6 +113,8 @@ module.exports = function() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
server.on("error", (err) => log.error(`${err}`));
|
||||||
|
|
||||||
server.listen(listenParams, () => {
|
server.listen(listenParams, () => {
|
||||||
if (typeof listenParams === "string") {
|
if (typeof listenParams === "string") {
|
||||||
log.info("Available on socket " + colors.green(listenParams));
|
log.info("Available on socket " + colors.green(listenParams));
|
||||||
@ -117,7 +138,10 @@ module.exports = function() {
|
|||||||
if (config.public) {
|
if (config.public) {
|
||||||
performAuthentication.call(socket, {});
|
performAuthentication.call(socket, {});
|
||||||
} else {
|
} else {
|
||||||
socket.emit("auth", {success: true});
|
socket.emit("auth", {
|
||||||
|
serverHash: serverHash,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
socket.on("auth", performAuthentication);
|
socket.on("auth", performAuthentication);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -184,15 +208,7 @@ function index(req, res, next) {
|
|||||||
Helper.config
|
Helper.config
|
||||||
);
|
);
|
||||||
data.gitCommit = Helper.getGitCommit();
|
data.gitCommit = Helper.getGitCommit();
|
||||||
data.themes = fs.readdirSync("client/themes/").filter(function(themeFile) {
|
data.themes = themes.getAll();
|
||||||
return themeFile.endsWith(".css");
|
|
||||||
}).map(function(css) {
|
|
||||||
const filename = css.slice(0, -4);
|
|
||||||
return {
|
|
||||||
name: filename.charAt(0).toUpperCase() + filename.slice(1),
|
|
||||||
filename: filename
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const policies = [
|
const policies = [
|
||||||
"default-src *",
|
"default-src *",
|
||||||
@ -215,7 +231,7 @@ function index(req, res, next) {
|
|||||||
res.render("index", data);
|
res.render("index", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
function initializeClient(socket, client, token) {
|
function initializeClient(socket, client, token, lastMessage) {
|
||||||
socket.emit("authorized");
|
socket.emit("authorized");
|
||||||
|
|
||||||
socket.on("disconnect", function() {
|
socket.on("disconnect", function() {
|
||||||
@ -379,11 +395,24 @@ function initializeClient(socket, client, token) {
|
|||||||
socket.join(client.id);
|
socket.join(client.id);
|
||||||
|
|
||||||
const sendInitEvent = (tokenToSend) => {
|
const sendInitEvent = (tokenToSend) => {
|
||||||
|
let networks = client.networks;
|
||||||
|
|
||||||
|
if (lastMessage > -1) {
|
||||||
|
// We need a deep cloned object because we are going to remove unneeded messages
|
||||||
|
networks = _.cloneDeep(networks);
|
||||||
|
|
||||||
|
networks.forEach((network) => {
|
||||||
|
network.channels.forEach((channel) => {
|
||||||
|
channel.messages = channel.messages.filter((m) => m.id > lastMessage);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
socket.emit("init", {
|
socket.emit("init", {
|
||||||
applicationServerKey: manager.webPush.vapidKeys.publicKey,
|
applicationServerKey: manager.webPush.vapidKeys.publicKey,
|
||||||
pushSubscription: client.config.sessions[token],
|
pushSubscription: client.config.sessions[token],
|
||||||
active: client.lastActiveChannel,
|
active: client.lastActiveChannel,
|
||||||
networks: client.networks,
|
networks: networks,
|
||||||
token: tokenToSend
|
token: tokenToSend
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -409,67 +438,11 @@ function initializeClient(socket, client, token) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function localAuth(client, user, password, callback) {
|
|
||||||
// If no user is found, or if the client has not provided a password,
|
|
||||||
// fail the authentication straight away
|
|
||||||
if (!client || !password) {
|
|
||||||
return callback(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If this user has no password set, fail the authentication
|
|
||||||
if (!client.config.password) {
|
|
||||||
log.error(`User ${colors.bold(user)} with no local password set tried to sign in. (Probably a LDAP user)`);
|
|
||||||
return callback(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
Helper.password
|
|
||||||
.compare(password, client.config.password)
|
|
||||||
.then((matching) => {
|
|
||||||
if (matching && Helper.password.requiresUpdate(client.config.password)) {
|
|
||||||
const hash = Helper.password.hash(password);
|
|
||||||
|
|
||||||
client.setPassword(hash, (success) => {
|
|
||||||
if (success) {
|
|
||||||
log.info(`User ${colors.bold(client.name)} logged in and their hashed password has been updated to match new security requirements`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
callback(matching);
|
|
||||||
}).catch((error) => {
|
|
||||||
log.error(`Error while checking users password. Error: ${error}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function ldapAuth(client, user, password, callback) {
|
|
||||||
var userDN = user.replace(/([,\\/#+<>;"= ])/g, "\\$1");
|
|
||||||
var bindDN = Helper.config.ldap.primaryKey + "=" + userDN + "," + Helper.config.ldap.baseDN;
|
|
||||||
|
|
||||||
var ldapclient = ldap.createClient({
|
|
||||||
url: Helper.config.ldap.url
|
|
||||||
});
|
|
||||||
|
|
||||||
ldapclient.on("error", function(err) {
|
|
||||||
log.error("Unable to connect to LDAP server", err);
|
|
||||||
callback(!err);
|
|
||||||
});
|
|
||||||
|
|
||||||
ldapclient.bind(bindDN, password, function(err) {
|
|
||||||
if (!err && !client) {
|
|
||||||
if (!manager.addUser(user, null)) {
|
|
||||||
log.error("Unable to create new user", user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ldapclient.unbind();
|
|
||||||
callback(!err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function performAuthentication(data) {
|
function performAuthentication(data) {
|
||||||
const socket = this;
|
const socket = this;
|
||||||
let client;
|
let client;
|
||||||
|
|
||||||
const finalInit = () => initializeClient(socket, client, data.token || null);
|
const finalInit = () => initializeClient(socket, client, data.token || null, data.lastMessage || -1);
|
||||||
|
|
||||||
const initClient = () => {
|
const initClient = () => {
|
||||||
client.ip = getClientIp(socket.request);
|
client.ip = getClientIp(socket.request);
|
||||||
@ -528,12 +501,17 @@ function performAuthentication(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Perform password checking
|
// Perform password checking
|
||||||
if (!Helper.config.public && Helper.config.ldap.enable) {
|
let auth = () => {
|
||||||
ldapAuth(client, data.user, data.password, authCallback);
|
log.error("None of the auth plugins is enabled");
|
||||||
} else {
|
};
|
||||||
localAuth(client, data.user, data.password, authCallback);
|
for (let i = 0; i < authPlugins.length; ++i) {
|
||||||
|
if (authPlugins[i].isEnabled()) {
|
||||||
|
auth = authPlugins[i].auth;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
auth(manager, client, data.user, data.password, authCallback);
|
||||||
|
}
|
||||||
|
|
||||||
function reverseDnsLookup(ip, callback) {
|
function reverseDnsLookup(ip, callback) {
|
||||||
dns.reverse(ip, (err, hostnames) => {
|
dns.reverse(ip, (err, hostnames) => {
|
||||||
|
@ -18,7 +18,7 @@ module.exports.write = function(user, network, chan, msg) {
|
|||||||
var format = Helper.config.logs.format || "YYYY-MM-DD HH:mm:ss";
|
var format = Helper.config.logs.format || "YYYY-MM-DD HH:mm:ss";
|
||||||
var tz = Helper.config.logs.timezone || "UTC+00:00";
|
var tz = Helper.config.logs.timezone || "UTC+00:00";
|
||||||
|
|
||||||
var time = moment().utcOffset(tz).format(format);
|
var time = moment(msg.time).utcOffset(tz).format(format);
|
||||||
var line = `[${time}] `;
|
var line = `[${time}] `;
|
||||||
|
|
||||||
var type = msg.type.trim();
|
var type = msg.type.trim();
|
||||||
|
2
test/fixtures/env.js
vendored
2
test/fixtures/env.js
vendored
@ -1,7 +1,7 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
global.log = {
|
global.log = {
|
||||||
error: () => console.error.apply(console, arguments),
|
error: () => console.error.apply(console, arguments), // eslint-disable-line no-console
|
||||||
warn: () => {},
|
warn: () => {},
|
||||||
info: () => {},
|
info: () => {},
|
||||||
debug: () => {},
|
debug: () => {},
|
||||||
|
148
test/plugins/auth/ldap.js
Normal file
148
test/plugins/auth/ldap.js
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const ldapAuth = require("../../../src/plugins/auth/ldap");
|
||||||
|
const Helper = require("../../../src/helper");
|
||||||
|
const ldap = require("ldapjs");
|
||||||
|
const expect = require("chai").expect;
|
||||||
|
|
||||||
|
const user = "johndoe";
|
||||||
|
const wrongUser = "eve";
|
||||||
|
const correctPassword = "loremipsum";
|
||||||
|
const wrongPassword = "dolorsitamet";
|
||||||
|
const baseDN = "ou=accounts,dc=example,dc=com";
|
||||||
|
const primaryKey = "uid";
|
||||||
|
const serverPort = 1389;
|
||||||
|
|
||||||
|
function normalizeDN(dn) {
|
||||||
|
return ldap.parseDN(dn).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startLdapServer(callback) {
|
||||||
|
const server = ldap.createServer();
|
||||||
|
|
||||||
|
const searchConf = Helper.config.ldap.searchDN;
|
||||||
|
const userDN = primaryKey + "=" + user + "," + baseDN;
|
||||||
|
|
||||||
|
// Two users are authorized: john doe and the root user in case of
|
||||||
|
// advanced auth (the user that does the search for john's actual
|
||||||
|
// bindDN)
|
||||||
|
const authorizedUsers = {};
|
||||||
|
authorizedUsers[normalizeDN(searchConf.rootDN)] = searchConf.rootPassword;
|
||||||
|
authorizedUsers[normalizeDN(userDN)] = correctPassword;
|
||||||
|
|
||||||
|
function authorize(req, res, next) {
|
||||||
|
const bindDN = req.connection.ldap.bindDN;
|
||||||
|
|
||||||
|
if (bindDN in authorizedUsers) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
return next(new ldap.InsufficientAccessRightsError());
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.keys(authorizedUsers).forEach(function(dn) {
|
||||||
|
server.bind(dn, function(req, res, next) {
|
||||||
|
const bindDN = req.dn.toString();
|
||||||
|
const password = req.credentials;
|
||||||
|
|
||||||
|
if (bindDN in authorizedUsers && authorizedUsers[bindDN] === password) {
|
||||||
|
req.connection.ldap.bindDN = req.dn;
|
||||||
|
res.end();
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
return next(new ldap.InsufficientAccessRightsError());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.search(searchConf.base, authorize, function(req, res) {
|
||||||
|
const obj = {
|
||||||
|
dn: userDN,
|
||||||
|
attributes: {
|
||||||
|
objectclass: ["person", "top"],
|
||||||
|
cn: ["john doe"],
|
||||||
|
sn: ["johnny"],
|
||||||
|
uid: ["johndoe"],
|
||||||
|
memberof: [baseDN]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (req.filter.matches(obj.attributes)) {
|
||||||
|
// TODO: check req.scope if ldapjs does not
|
||||||
|
res.send(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(serverPort, callback);
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
function testLdapAuth() {
|
||||||
|
// Create mock manager and client. When client is true, manager should not
|
||||||
|
// be used. But ideally the auth plugin should not use any of those.
|
||||||
|
const manager = {};
|
||||||
|
const client = true;
|
||||||
|
|
||||||
|
it("should successfully authenticate with correct password", function(done) {
|
||||||
|
ldapAuth.auth(manager, client, user, correctPassword, function(valid) {
|
||||||
|
expect(valid).to.equal(true);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail to authenticate with incorrect password", function(done) {
|
||||||
|
ldapAuth.auth(manager, client, user, wrongPassword, function(valid) {
|
||||||
|
expect(valid).to.equal(false);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail to authenticate with incorrect username", function(done) {
|
||||||
|
ldapAuth.auth(manager, client, wrongUser, correctPassword, function(valid) {
|
||||||
|
expect(valid).to.equal(false);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("LDAP authentication plugin", function() {
|
||||||
|
before(function(done) {
|
||||||
|
this.server = startLdapServer(done);
|
||||||
|
});
|
||||||
|
after(function(done) {
|
||||||
|
this.server.close();
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(function(done) {
|
||||||
|
Helper.config.public = false;
|
||||||
|
Helper.config.ldap.enable = true;
|
||||||
|
Helper.config.ldap.url = "ldap://localhost:" + String(serverPort);
|
||||||
|
Helper.config.ldap.primaryKey = primaryKey;
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("LDAP authentication availability", function() {
|
||||||
|
it("checks that the configuration is correctly tied to isEnabled()", function(done) {
|
||||||
|
Helper.config.ldap.enable = true;
|
||||||
|
expect(ldapAuth.isEnabled()).to.equal(true);
|
||||||
|
Helper.config.ldap.enable = false;
|
||||||
|
expect(ldapAuth.isEnabled()).to.equal(false);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Simple LDAP authentication (predefined DN pattern)", function() {
|
||||||
|
Helper.config.ldap.baseDN = baseDN;
|
||||||
|
testLdapAuth();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Advanced LDAP authentication (DN found by a prior search query)", function() {
|
||||||
|
delete Helper.config.ldap.baseDN;
|
||||||
|
testLdapAuth();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -37,6 +37,11 @@ describe("Server", () => {
|
|||||||
describe("WebSockets", () => {
|
describe("WebSockets", () => {
|
||||||
let client;
|
let client;
|
||||||
|
|
||||||
|
before((done) => {
|
||||||
|
Helper.config.public = true;
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
client = io(webURL, {
|
client = io(webURL, {
|
||||||
path: "/socket.io/",
|
path: "/socket.io/",
|
||||||
|
@ -79,8 +79,6 @@ if (process.env.NODE_ENV === "production") {
|
|||||||
sourceMap: true,
|
sourceMap: true,
|
||||||
comments: false
|
comments: false
|
||||||
}));
|
}));
|
||||||
} else {
|
|
||||||
console.log("Building in development mode, bundles will not be minified.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = config;
|
module.exports = config;
|
||||||
|
Loading…
Reference in New Issue
Block a user