Merge branch 'master' into pr-proper-ldap

This commit is contained in:
Élie Michel 2017-08-29 08:42:26 +02:00
commit 19710b90c0
177 changed files with 8822 additions and 2423 deletions

View File

@ -18,7 +18,3 @@ trim_trailing_whitespace = false
[*.{json,yml}] [*.{json,yml}]
indent_style = space indent_style = space
indent_size = 2 indent_size = 2
[.eslintrc]
indent_style = space
indent_size = 2

View File

@ -9,39 +9,53 @@ env:
node: true node: true
rules: rules:
arrow-body-style: 2
arrow-parens: [2, always]
arrow-spacing: 2
block-scoped-var: 2 block-scoped-var: 2
block-spacing: [2, always] block-spacing: [2, always]
brace-style: [2, 1tbs] brace-style: [2, 1tbs]
comma-dangle: 0 comma-dangle: 0
curly: [2, all] curly: [2, all]
dot-location: [2, property]
dot-notation: 2 dot-notation: 2
eol-last: 2
eqeqeq: 2 eqeqeq: 2
handle-callback-err: 2 handle-callback-err: 2
indent: [2, tab, { "MemberExpression": 1 }] indent: [2, tab]
key-spacing: [2, {beforeColon: false, afterColon: true}] key-spacing: [2, {beforeColon: false, afterColon: true}]
keyword-spacing: [2, {before: true, after: true}] keyword-spacing: [2, {before: true, after: true}]
linebreak-style: [2, unix] linebreak-style: [2, unix]
no-compare-neg-zero: 2 no-catch-shadow: 2
no-confusing-arrow: 2
no-console: 0 no-console: 0
no-control-regex: 0 no-control-regex: 0
no-duplicate-imports: 2
no-else-return: 2 no-else-return: 2
no-implicit-globals: 2 no-implicit-globals: 2
no-multi-spaces: 2 no-multi-spaces: 2
no-multiple-empty-lines: [2, { "max": 1 }]
no-shadow: 2 no-shadow: 2
no-template-curly-in-string: 2 no-template-curly-in-string: 2
no-trailing-spaces: 2 no-trailing-spaces: 2
no-unsafe-negation: 2 no-unsafe-negation: 2
no-useless-escape: 2 no-useless-computed-key: 2
no-useless-return: 2 no-useless-return: 2
object-curly-spacing: [2, never] object-curly-spacing: [2, never]
padded-blocks: [2, never]
prefer-const: 2
quote-props: [2, as-needed] quote-props: [2, as-needed]
quotes: [2, double, avoid-escape] quotes: [2, double, avoid-escape]
semi-style: [2, last]
semi: [2, always] semi: [2, always]
space-before-blocks: 2 space-before-blocks: 2
space-before-function-paren: [2, never] space-before-function-paren: [2, never]
space-in-parens: [2, never]
space-infix-ops: 2 space-infix-ops: 2
spaced-comment: [2, always] spaced-comment: [2, always]
strict: 2 strict: 2
template-curly-spacing: 2
yoda: 2
globals: globals:
log: false log: false

1
.gitignore vendored
View File

@ -1,5 +1,6 @@
node_modules/ node_modules/
npm-debug.log npm-debug.log
package-lock.json
.nyc_output/ .nyc_output/
coverage/ coverage/

1
.lounge_home Normal file
View File

@ -0,0 +1 @@
~/.lounge

View File

@ -2,19 +2,11 @@
# npm-debug.log and node_modules/ are ignored by default. # npm-debug.log and node_modules/ are ignored by default.
# See https://docs.npmjs.com/misc/developers#keeping-files-out-of-your-package # See https://docs.npmjs.com/misc/developers#keeping-files-out-of-your-package
.*
client/js/bundle.vendor.js.map client/js/bundle.vendor.js.map
client/views/ client/views/
coverage/ coverage/
scripts/ scripts/
test/ test/
.editorconfig
.eslintignore
.eslintrc.yml
.gitattributes
.gitignore
.nycrc
.npmignore
.stylelintrc
.travis.yml
appveyor.yml appveyor.yml
webpack.config.js webpack.config.js

1
.npmrc Normal file
View File

@ -0,0 +1 @@
save-exact = true

4
.nycrc
View File

@ -3,8 +3,8 @@
"exclude": [ "exclude": [
"client/js/bundle.js", "client/js/bundle.js",
"client/js/bundle.vendor.js", "client/js/bundle.vendor.js",
"coverage/", "test/",
"test/" "webpack.config.js"
], ],
"reporter": [ "reporter": [
"lcov", "lcov",

View File

@ -1,77 +0,0 @@
{
"ignoreFiles": [
"client/css/bootstrap.css",
"coverage/**/*.css"
],
"rules": {
"at-rule-empty-line-before": ["always", {
"except": ["blockless-after-blockless", "first-nested"],
"ignore": ["after-comment"]
}],
"block-closing-brace-newline-after": "always",
"block-closing-brace-newline-before": "always-multi-line",
"block-closing-brace-space-before": "always-single-line",
"block-no-empty": true,
"block-opening-brace-newline-after": "always-multi-line",
"block-opening-brace-space-after": "always-single-line",
"block-opening-brace-space-before": "always",
"color-hex-case": "lower",
"color-hex-length": "short",
"color-no-invalid-hex": true,
"comment-empty-line-before": ["always", {
"except": ["first-nested"],
"ignore": ["stylelint-command"]
}],
"comment-whitespace-inside": "always",
"declaration-bang-space-after": "never",
"declaration-bang-space-before": "always",
"declaration-block-semicolon-newline-after": "always-multi-line",
"declaration-block-semicolon-space-after": "always-single-line",
"declaration-block-semicolon-space-before": "never",
"declaration-block-single-line-max-declarations": 1,
"declaration-colon-newline-after": "always-multi-line",
"declaration-colon-space-after": "always-single-line",
"declaration-colon-space-before": "never",
"function-calc-no-unspaced-operator": true,
"function-comma-newline-after": "always-multi-line",
"function-comma-space-after": "always-single-line",
"function-comma-space-before": "never",
"function-parentheses-newline-inside": "always-multi-line",
"function-parentheses-space-inside": "never-single-line",
"function-whitespace-after": "always",
"function-url-quotes": "always",
"indentation": "tab",
"max-empty-lines": 1,
"media-feature-colon-space-after": "always",
"media-feature-colon-space-before": "never",
"media-feature-range-operator-space-after": "always",
"media-feature-range-operator-space-before": "always",
"media-query-list-comma-newline-after": "always-multi-line",
"media-query-list-comma-space-after": "always-single-line",
"media-query-list-comma-space-before": "never",
"media-feature-parentheses-space-inside": "never",
"no-eol-whitespace": true,
"no-missing-end-of-source-newline": true,
"number-leading-zero": "never",
"number-no-trailing-zeros": true,
"length-zero-no-unit": true,
"declaration-block-no-duplicate-properties": [true, {
"ignore": ["consecutive-duplicates"]
}],
"declaration-block-no-shorthand-property-overrides": true,
"rule-empty-line-before": ["always-multi-line", {
"except": ["first-nested"],
"ignore": ["after-comment"],
}],
"declaration-block-trailing-semicolon": "always",
"selector-combinator-space-after": "always",
"selector-combinator-space-before": "always",
"selector-list-comma-newline-after": "always",
"selector-list-comma-space-before": "never",
"selector-pseudo-element-colon-notation": "single",
"string-quotes": "double",
"value-list-comma-newline-after": "always-multi-line",
"value-list-comma-space-after": "always-single-line",
"value-list-comma-space-before": "never"
}
}

8
.stylelintrc.yml Normal file
View File

@ -0,0 +1,8 @@
extends: stylelint-config-standard
ignoreFiles:
- coverage/**/*.css
- client/css/bootstrap.css
rules:
indentation: tab

View File

@ -1,13 +1,13 @@
language: node_js language: node_js
node_js: node_js:
- 7 # Current stable - 8 # Current stable (Active LTS from 2017-10-01)
- 6 # Active LTS until 2018-04-18 - 6 # Active LTS until 2018-04-18
- 4 # Active LTS until 2017-04-01 - 4 # Maintenance LTS until 2018-04-01
matrix: matrix:
fast_finish: true fast_finish: true
include: include:
- node_js: 7 # Version used to deploy to npm registry - node_js: 8 # Version used to deploy to npm registry
env: BUILD_ENV=production env: BUILD_ENV=production
cache: cache:
@ -29,7 +29,7 @@ deploy:
api_key: api_key:
secure: I9iN31GWI+Mz0xPw81N7qh1M6uidB+3BmiPUXt8QigX45zwp9EhvfZ0U/AIdUyQwzK2RK1zLRQSt+2/1jyeVi+U+AAsRRmaAUx8iqKaQPAkPnQtElolgRP04WSgo7fvNejfM7zS939bQNKG3RlSm04yPgu+ke2igf799p2bpFe2LtyoEeIiUfrUkBiMSpMguN9XF8a7jqCyIouTKjXHR24RmzJ9r7ZoMV27yQauS7XlD81bontzNRZxTytDKdJpZ+sxGIT9mbbtM4LUFX8MeNe3p/bjWavEhrO0ZIpkbOfS/L/w1375YDoNPXxCs288lnGUH+NbGNAEfn+BTz8cmUp7jI7QWR/kNACPeopdAX4OdZxT8wfQcfQZrfCuSpKciOMC7vGgPpQqjQ61t1RKcKs9VUnwC0SwWjyo8LlzkFKnP1ks0eDGYsSoPLdpC9+76UmePkQdxMhscO8TOgkOCcsTMLiyt6ABGOGKu2iE5SsjUYtPiSiRzSBAQENoO560+xBSVTKwqvvhzUAIt4AuAQSgsFjAylDdyzKoObHX12hBdALrqSOOSVwwIQ5/jTgNAsilURHo7KPD407PhRnLOsvumL0qg4sr9S1hjuUKnNla5dg9GY8FVjJ+b2t0A2vgfG1pR1e3vrJRXrpkfRorhmjvKAk2o5you5pQ1Itty7rM= secure: I9iN31GWI+Mz0xPw81N7qh1M6uidB+3BmiPUXt8QigX45zwp9EhvfZ0U/AIdUyQwzK2RK1zLRQSt+2/1jyeVi+U+AAsRRmaAUx8iqKaQPAkPnQtElolgRP04WSgo7fvNejfM7zS939bQNKG3RlSm04yPgu+ke2igf799p2bpFe2LtyoEeIiUfrUkBiMSpMguN9XF8a7jqCyIouTKjXHR24RmzJ9r7ZoMV27yQauS7XlD81bontzNRZxTytDKdJpZ+sxGIT9mbbtM4LUFX8MeNe3p/bjWavEhrO0ZIpkbOfS/L/w1375YDoNPXxCs288lnGUH+NbGNAEfn+BTz8cmUp7jI7QWR/kNACPeopdAX4OdZxT8wfQcfQZrfCuSpKciOMC7vGgPpQqjQ61t1RKcKs9VUnwC0SwWjyo8LlzkFKnP1ks0eDGYsSoPLdpC9+76UmePkQdxMhscO8TOgkOCcsTMLiyt6ABGOGKu2iE5SsjUYtPiSiRzSBAQENoO560+xBSVTKwqvvhzUAIt4AuAQSgsFjAylDdyzKoObHX12hBdALrqSOOSVwwIQ5/jTgNAsilURHo7KPD407PhRnLOsvumL0qg4sr9S1hjuUKnNla5dg9GY8FVjJ+b2t0A2vgfG1pR1e3vrJRXrpkfRorhmjvKAk2o5you5pQ1Itty7rM=
on: on:
node: 7 node: 8
condition: "$BUILD_ENV = production" condition: "$BUILD_ENV = production"
tags: true tags: true
repo: thelounge/lounge repo: thelounge/lounge

View File

@ -1,7 +1,6 @@
# Change Log # Change Log
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/).
<!-- <!--
Use the following template for each new release, built on recommendations from http://keepachangelog.com/. Use the following template for each new release, built on recommendations from http://keepachangelog.com/.
@ -9,7 +8,7 @@ Use the following template for each new release, built on recommendations from h
```md ```md
## vX.Y.Z - YYYY-MM-DD ## vX.Y.Z - YYYY-MM-DD
For more details, [see the full changelog](https://github.com/thelounge/lounge/compare/vPRE.VIO.US...vX.Y.Z) and [milestone](https://github.com/thelounge/lounge/milestone/XXX). For more details, [see the full changelog](https://github.com/thelounge/lounge/compare/vPRE.VIO.US...vX.Y.Z) and [milestone](https://github.com/thelounge/lounge/milestone/XX?closed=1).
DESCRIPTION, ANNOUNCEMENT, ... DESCRIPTION, ANNOUNCEMENT, ...
@ -32,9 +31,378 @@ All sections are explained on the link above, they are all optional, and each of
``` ```
--> -->
## v2.4.0 - 2017-07-30
For more details, [see the full changelog](https://github.com/thelounge/lounge/compare/v2.3.2...v2.4.0) and [milestone](https://github.com/thelounge/lounge/milestone/25?closed=1).
This release improves link and image previews a great deal! On the menu:
- Up to 5 previews are now displayed instead of 1
- All previews on the current channel can now be hidden or displayed using the `/collapse` and `/expand` commands
- Thumbnails can be opened in a fullscreen viewer without leaving the app by clicking on them, and cycled using the previous/next buttons or by hitting <kbd></kbd> and <kbd></kbd>
- Say bye to mixed content warnings: The Lounge can now proxy all images (opt-in option in the server settings) for better privacy
- Title and description are improved overall
Also in this release, auto-complete feature now has an opt-out option in the client settings, and emoji can be searched using fuzzy-matching:
<img width="241" alt="The Lounge - Emoji fuzzy-matching" src="https://user-images.githubusercontent.com/113730/28757682-54276b5a-7556-11e7-9e4b-ce1d19d7b678.png">
### Added
- Add `title` attributes to previews ([#1291](https://github.com/thelounge/lounge/pull/1291) by [@astorije](https://github.com/astorije))
- Allow opting out of autocomplete ([#1294](https://github.com/thelounge/lounge/pull/1294) by [@awalgarg](https://github.com/awalgarg))
- Add collapse/expand commands to toggle all previews ([#1309](https://github.com/thelounge/lounge/pull/1309) by [@astorije](https://github.com/astorije))
- An image viewer popup for thumbnails and image previews, with buttons to previous/next images ([#1325](https://github.com/thelounge/lounge/pull/1325), [#1365](https://github.com/thelounge/lounge/pull/1365), [#1368](https://github.com/thelounge/lounge/pull/1368), [#1367](https://github.com/thelounge/lounge/pull/1367) by [@astorije](https://github.com/astorije), [#1370](https://github.com/thelounge/lounge/pull/1370) by [@xPaw](https://github.com/xPaw))
- Store preview images on disk for privacy, security and caching ([#1307](https://github.com/thelounge/lounge/pull/1307) by [@xPaw](https://github.com/xPaw))
- Emoji fuzzy-matching ([#1334](https://github.com/thelounge/lounge/pull/1334) by [@MaxLeiter](https://github.com/MaxLeiter))
### Changed
- Check status code in link prefetcher ([#1260](https://github.com/thelounge/lounge/pull/1260) by [@xPaw](https://github.com/xPaw))
- Check `og:description` before `description` tag in previews ([#1255](https://github.com/thelounge/lounge/pull/1255) by [@xPaw](https://github.com/xPaw))
- Check `og:title` before `title` tag in previews ([#1256](https://github.com/thelounge/lounge/pull/1256) by [@xPaw](https://github.com/xPaw))
- Do not display preview if there is nothing to preview ([#1273](https://github.com/thelounge/lounge/pull/1273) by [@xPaw](https://github.com/xPaw))
- Increase max downloaded bytes for link preview ([#1274](https://github.com/thelounge/lounge/pull/1274) by [@xPaw](https://github.com/xPaw))
- Refactor link previews ([#1276](https://github.com/thelounge/lounge/pull/1276) by [@xPaw](https://github.com/xPaw), [#1378](https://github.com/thelounge/lounge/pull/1378) by [@astorije](https://github.com/astorije))
- Support multiple previews per message ([#1303](https://github.com/thelounge/lounge/pull/1303), [#1324](https://github.com/thelounge/lounge/pull/1324), [#1335](https://github.com/thelounge/lounge/pull/1335), [#1348](https://github.com/thelounge/lounge/pull/1348), [#1347](https://github.com/thelounge/lounge/pull/1347), [#1353](https://github.com/thelounge/lounge/pull/1353) by [@astorije](https://github.com/astorije))
- Add `mask-icon` for pinned safari tab ([#1329](https://github.com/thelounge/lounge/pull/1329) by [@MaxLeiter](https://github.com/MaxLeiter))
- Lazily load user list in channels on init, keep autocompletion sort on server ([#1194](https://github.com/thelounge/lounge/pull/1194) by [@xPaw](https://github.com/xPaw))
- Keep track of preview visibility on the server so it persists at page reload ([#1366](https://github.com/thelounge/lounge/pull/1366) by [@astorije](https://github.com/astorije))
- Bump express and socket.io to their latest patch versions ([#1312](https://github.com/thelounge/lounge/pull/1312) by [@astorije](https://github.com/astorije))
- Update production dependencies to their latest versions, by [Greenkeeper](https://greenkeeper.io/) 🚀:
- `commander` ([#1257](https://github.com/thelounge/lounge/pull/1257), [#1292](https://github.com/thelounge/lounge/pull/1292))
- `jquery-textcomplete` ([#1279](https://github.com/thelounge/lounge/pull/1279), [#1321](https://github.com/thelounge/lounge/pull/1321))
- `fs-extra` ([#1332](https://github.com/thelounge/lounge/pull/1332))
- `semver` ([#1369](https://github.com/thelounge/lounge/pull/1369))
### Removed
- Remove hostname prettifier ([#1306](https://github.com/thelounge/lounge/pull/1306) by [@xPaw](https://github.com/xPaw))
- Remove `X-UA-Compatible` ([#1328](https://github.com/thelounge/lounge/pull/1328) by [@xPaw](https://github.com/xPaw))
### Fixed
- Make sure thumbnail is a valid image in previews ([#1254](https://github.com/thelounge/lounge/pull/1254) by [@xPaw](https://github.com/xPaw))
- Parse `X-Forwarded-For` header correctly ([#1202](https://github.com/thelounge/lounge/pull/1202) by [@xPaw](https://github.com/xPaw))
- Do not truncate link previews if viewport can fit more text ([#1293](https://github.com/thelounge/lounge/pull/1293) by [@xPaw](https://github.com/xPaw))
- Fix too big line height previews text on Crypto ([#1296](https://github.com/thelounge/lounge/pull/1296) by [@astorije](https://github.com/astorije))
- Fix background color contrast on Zenburn previews ([#1297](https://github.com/thelounge/lounge/pull/1297) by [@astorije](https://github.com/astorije))
- Fix jumps when toggling link preview ([#1298](https://github.com/thelounge/lounge/pull/1298) by [@xPaw](https://github.com/xPaw))
- Fix losing network settings ([#1305](https://github.com/thelounge/lounge/pull/1305) by [@xPaw](https://github.com/xPaw))
- Fix missing transitions ([#1314](https://github.com/thelounge/lounge/pull/1314), [#1336](https://github.com/thelounge/lounge/pull/1336), [#1374](https://github.com/thelounge/lounge/pull/1374) by [@astorije](https://github.com/astorije), [#1117](https://github.com/thelounge/lounge/pull/1117) by [@bews](https://github.com/bews))
- Fix incorrect mode on kick target ([#1352](https://github.com/thelounge/lounge/pull/1352) by [@xPaw](https://github.com/xPaw))
- Correctly show whitespace and newlines in messages ([#1242](https://github.com/thelounge/lounge/pull/1242) by [@starquake](https://github.com/starquake), [#1359](https://github.com/thelounge/lounge/pull/1359) by [@xPaw](https://github.com/xPaw))
- Hide overflow on entire message row ([#1361](https://github.com/thelounge/lounge/pull/1361) by [@starquake](https://github.com/starquake))
- Fix link previews not truncating correctly ([#1363](https://github.com/thelounge/lounge/pull/1363) by [@xPaw](https://github.com/xPaw))
### Documentation
In the main repository:
- Remove mention in CHANGELOG that The Lounge uses Semantic Versioning ([#1269](https://github.com/thelounge/lounge/pull/1269) by [@astorije](https://github.com/astorije))
- Remove `devDependencies` badge on README ([#1267](https://github.com/thelounge/lounge/pull/1267) by [@astorije](https://github.com/astorije))
- Reword link preview settings to better match reality ([#1310](https://github.com/thelounge/lounge/pull/1310) by [@astorije](https://github.com/astorije))
- Update screenshot in README ([#1326](https://github.com/thelounge/lounge/pull/1326) by [@MaxLeiter](https://github.com/MaxLeiter))
- Update README badge to new demo URL ([#1345](https://github.com/thelounge/lounge/pull/1345) by [@MaxLeiter](https://github.com/MaxLeiter))
- Update README for when to run `npm run build` ([#1319](https://github.com/thelounge/lounge/pull/1319) by [@MaxLeiter](https://github.com/MaxLeiter))
On the website:
- Update demo URL to new demo ([#70](https://github.com/thelounge/thelounge.github.io/pull/70) by [@MaxLeiter](https://github.com/MaxLeiter))
### Internals
- Move nickname rendering to a single template ([#1252](https://github.com/thelounge/lounge/pull/1252) by [@xPaw](https://github.com/xPaw))
- Ignore all dotfiles in `.npmignore` ([#1287](https://github.com/thelounge/lounge/pull/1287) by [@xPaw](https://github.com/xPaw))
- Add `.npmrc` file with `save-exact` set to `true` so packages are saved already pinned ([#1284](https://github.com/thelounge/lounge/pull/1284) by [@MaxLeiter](https://github.com/MaxLeiter))
- Do not hardcode vendor bundles in webpack configuration ([#1280](https://github.com/thelounge/lounge/pull/1280) by [@xPaw](https://github.com/xPaw))
- Prepare for `SOURCE` CTCP command, when `irc-framework` supports it ([#1284](https://github.com/thelounge/lounge/pull/1284) by [@MaxLeiter](https://github.com/MaxLeiter))
- Change "Show older messages" to use `id` rather than count ([#1354](https://github.com/thelounge/lounge/pull/1354) by [@YaManicKill](https://github.com/YaManicKill))
- Update development dependencies to their latest versions, by [Greenkeeper](https://greenkeeper.io/) 🚀:
- `eslint` ([#1264](https://github.com/thelounge/lounge/pull/1264), [#1272](https://github.com/thelounge/lounge/pull/1272), [#1315](https://github.com/thelounge/lounge/pull/1315), [#1362](https://github.com/thelounge/lounge/pull/1362))
- `nyc` ([#1277](https://github.com/thelounge/lounge/pull/1277))
- `stylelint` ([#1278](https://github.com/thelounge/lounge/pull/1278), [#1320](https://github.com/thelounge/lounge/pull/1320), [#1340](https://github.com/thelounge/lounge/pull/1340))
- `babel-loader` ([#1282](https://github.com/thelounge/lounge/pull/1282))
- `babel-preset-env` ([#1295](https://github.com/thelounge/lounge/pull/1295))
- `webpack` ([#1308](https://github.com/thelounge/lounge/pull/1308), [#1322](https://github.com/thelounge/lounge/pull/1322), [#1338](https://github.com/thelounge/lounge/pull/1338), [#1371](https://github.com/thelounge/lounge/pull/1371), [#1376](https://github.com/thelounge/lounge/pull/1376))
- `chai` ([#1323](https://github.com/thelounge/lounge/pull/1323))
## v2.4.0-rc.2 - 2017-07-27 [Pre-release]
[See the full changelog](https://github.com/thelounge/lounge/compare/v2.4.0-rc.1...v2.4.0-rc.2)
This is a release candidate for v2.4.0 to ensure maximum stability for public release.
Please refer to the commit list given above for a complete list of changes, or wait for the stable release to get a thoroughly prepared change log entry.
As with all pre-releases, this version requires explicit use of the `next` tag to be installed:
```sh
npm install -g thelounge@next
```
## v2.4.0-rc.1 - 2017-07-27 [Pre-release]
[See the full changelog](https://github.com/thelounge/lounge/compare/v2.3.2...v2.4.0-rc.1)
This is a release candidate for v2.4.0 to ensure maximum stability for public release.
Please refer to the commit list given above for a complete list of changes, or wait for the stable release to get a thoroughly prepared change log entry.
As with all pre-releases, this version requires explicit use of the `next` tag to be installed:
```sh
npm install -g thelounge@next
```
## v2.3.2 - 2017-06-25
For more details, [see the full changelog](https://github.com/thelounge/lounge/compare/v2.3.1...v2.3.2) and [milestone](https://github.com/thelounge/lounge/milestone/24?closed=1).
This patch releases brings a lot of fixes and small improvements here and there, as well as the ability to display seconds in timestamps, a long-awaited feature!
### Added
- Add a client option to display seconds in timestamps ([#1141](https://github.com/thelounge/lounge/pull/1141) by [@bews](https://github.com/bews))
- Add "Reload page" button when the client fails to load ([#1150](https://github.com/thelounge/lounge/pull/1150) by [@bews](https://github.com/bews))
### Changed
- Treat `click` as a read activity ([#1214](https://github.com/thelounge/lounge/pull/1214) by [@xPaw](https://github.com/xPaw))
- Fade out for long nicks ([#1158](https://github.com/thelounge/lounge/pull/1158) by [@bews](https://github.com/bews), [#1253](https://github.com/thelounge/lounge/pull/1253) by [@xPaw](https://github.com/xPaw))
- Include trickery to reduce paints and improve performance ([#1120](https://github.com/thelounge/lounge/pull/1120) by [@xPaw](https://github.com/xPaw), [#1083](https://github.com/thelounge/lounge/pull/1083) by [@bews](https://github.com/bews))
- Make everything un-selectable by default ([#1233](https://github.com/thelounge/lounge/pull/1233) by [@xPaw](https://github.com/xPaw))
- Handle images with unknown size in prefetch ([#1246](https://github.com/thelounge/lounge/pull/1246) by [@bews](https://github.com/bews))
- Update production dependencies to their latest versions, by [Greenkeeper](https://greenkeeper.io/) 🚀:
- `spdy` ([#1184](https://github.com/thelounge/lounge/pull/1184))
### Fixed
- Stop showing the unread messages marker when `joins`/`parts`/`quits`/etc. are hidden ([#1016](https://github.com/thelounge/lounge/pull/1016) by [@swordbeta](https://github.com/swordbeta))
- Correctly finish scroll animation when using page keys ([#1244](https://github.com/thelounge/lounge/pull/1244) by [@xPaw](https://github.com/xPaw))
- Hide link time element on small devices ([#1261](https://github.com/thelounge/lounge/pull/1261) by [@xPaw](https://github.com/xPaw))
- Fix MOTD underline in Safari ([#1217](https://github.com/thelounge/lounge/pull/1217) by [@MaxLeiter](https://github.com/MaxLeiter))
### Documentation
In the main repository:
- Clarify kilobyte ambiguity ([#1248](https://github.com/thelounge/lounge/pull/1248) by [@xPaw](https://github.com/xPaw))
- Fix stray end tag ([#1251](https://github.com/thelounge/lounge/pull/1251) by [@xPaw](https://github.com/xPaw))
### Internals
- Update to ESLint 4 and enforce extra rules ([#1231](https://github.com/thelounge/lounge/pull/1231) by [@xPaw](https://github.com/xPaw))
- Improve the PR tester script a bit ([#1240](https://github.com/thelounge/lounge/pull/1240) by [@astorije](https://github.com/astorije))
- Add modules for socket events ([#1175](https://github.com/thelounge/lounge/pull/1175) by [@YaManicKill](https://github.com/YaManicKill))
- Ignore `package-lock.json` ([#1247](https://github.com/thelounge/lounge/pull/1247) by [@xPaw](https://github.com/xPaw))
- Use `stylelint-config-standard` ([#1249](https://github.com/thelounge/lounge/pull/1249) by [@xPaw](https://github.com/xPaw))
- Update development dependencies to their latest versions, by [Greenkeeper](https://greenkeeper.io/) 🚀:
- `babel-core` ([#1212](https://github.com/thelounge/lounge/pull/1212))
- `babel-loader` ([#1245](https://github.com/thelounge/lounge/pull/1245))
- `nyc` ([#1198](https://github.com/thelounge/lounge/pull/1198))
- `stylelint` ([#1215](https://github.com/thelounge/lounge/pull/1215), [#1230](https://github.com/thelounge/lounge/pull/1230))
- `chai` ([#1206](https://github.com/thelounge/lounge/pull/1206))
- `webpack` ([#1238](https://github.com/thelounge/lounge/pull/1238))
## v2.3.1 - 2017-06-09
For more details, [see the full changelog](https://github.com/thelounge/lounge/compare/v2.3.0...v2.3.1) and [milestone](https://github.com/thelounge/lounge/milestone/23?closed=1).
This release mostly fixes a few bugs, as listed below.
### Changed
- Keep original `<title>` name when changing the title ([#1205](https://github.com/thelounge/lounge/pull/1205) by [@xPaw](https://github.com/xPaw))
- Update production dependencies to their latest versions, by [Greenkeeper](https://greenkeeper.io/) 🚀:
- `handlebars` ([#1179](https://github.com/thelounge/lounge/pull/1179))
### Fixed
- Do not store unnecessary information in user objects ([#1195](https://github.com/thelounge/lounge/pull/1195) by [@xPaw](https://github.com/xPaw))
- Correctly configure client socket transports ([#1197](https://github.com/thelounge/lounge/pull/1197) by [@xPaw](https://github.com/xPaw))
- Fix network name not being set when `displayNetwork` is `false` ([#1211](https://github.com/thelounge/lounge/pull/1211) by [@xPaw](https://github.com/xPaw))
### Security
- Do not store passwords in settings storage ([#1204](https://github.com/thelounge/lounge/pull/1204) by [@xPaw](https://github.com/xPaw))
### Internals
- Fix `localtime` test to correctly use UTC ([#1201](https://github.com/thelounge/lounge/pull/1201) by [@xPaw](https://github.com/xPaw))
- Update Node.js versions for Travis CI ([#1191](https://github.com/thelounge/lounge/pull/1191) by [@YaManicKill](https://github.com/YaManicKill))
- Update development dependencies to their latest versions, by [Greenkeeper](https://greenkeeper.io/) 🚀:
- `mocha` ([#1170](https://github.com/thelounge/lounge/pull/1170))
- `webpack` ([#1183](https://github.com/thelounge/lounge/pull/1183))
- `babel-preset-env` ([#1177](https://github.com/thelounge/lounge/pull/1177))
## v2.3.0 - 2017-06-08
For more details, [see the full changelog](https://github.com/thelounge/lounge/compare/v2.2.2...v2.3.0) and [milestone](https://github.com/thelounge/lounge/milestone/9?closed=1).
What a release! Our biggest one since the v2.0.0 [release](https://github.com/thelounge/lounge/releases/tag/v2.0.0) / [milestone](https://github.com/thelounge/lounge/milestone/1?closed=1)!
Expect a lot of new cool stuff, tons of bug fixes and performance improvements.
Thanks to all 16 contributors (!!) who pitched in for this release, open source at its finest!
On the server side, The Lounge now supports an auto-away mechanism, stores channel keys across restarts and key changes, and supports a new SSL CA bundle option in the configuration file.
Users of the client will notice some changes as well:
- A bunch of new hotkeys to style messages (bold, italic, underline, foreground/background color), all listed in the Help window
- A new autocomplete mechanism for emoji, users, channels, commands, and colors:
<img alt="The Lounge - Auto-completion" src="https://user-images.githubusercontent.com/113730/26863276-a565fad8-4b1f-11e7-8aa4-21bb812c2568.gif" width=500>
Note that due to the new nick autocomplete, we removed the now unnecessary nick cycle button that was temporarily added in the meantime. Lots of users have reported it had been broken by a previous release anyway.
- Support of page up/down keys to browse the current chat
- Friendliness-bump of time-related tooltips and date marker:
![The Lounge - Timestamp tooltips](https://user-images.githubusercontent.com/113730/26863323-f57cb85e-4b1f-11e7-9b4c-27b62d518af5.gif) &nbsp;&nbsp;&nbsp; ![The Lounge - Friendly date marker](https://user-images.githubusercontent.com/113730/26863322-f577f634-4b1f-11e7-8131-c1b3f3ffe743.gif)
- Support of browsers' Back/Forward actions:
<img alt="The Lounge - Support of browser Back/Forward" src="https://user-images.githubusercontent.com/113730/26863320-f5761efe-4b1f-11e7-8fb4-de2c5c34cca3.gif" width=300>
- Better and more discreet inline previews for links and images:
<img alt="The Lounge - Link preview" src="https://user-images.githubusercontent.com/113730/26863418-887b9364-4b20-11e7-8016-1b5367690d7e.png" width=400><br>
<img alt="The Lounge - Image preview" src="https://user-images.githubusercontent.com/113730/26863419-887bcc4e-4b20-11e7-9055-1913a9aba0e4.png" width=300>
- Improved channel list with `/list`
- Support for `/ban`, `/unban` and `/banlist`
- Fuzzy-matching of the user list search to find folks more easily:
![The Lounge - Fuzzy matching in the user list](https://user-images.githubusercontent.com/113730/26863472-c86b58c4-4b20-11e7-84c1-f66ee8d3e99b.gif)
That's all for this release, and onto the next one now!
### Added
- Add `data-from` attribute to allow styling messages from specific users ([#978](https://github.com/thelounge/lounge/pull/978) by [@williamboman](https://github.com/williamboman))
- Auto away when no clients are connected ([#775](https://github.com/thelounge/lounge/pull/775), [#1104](https://github.com/thelounge/lounge/pull/1104) by [@xPaw](https://github.com/xPaw))
- Implement color hotkeys ([#810](https://github.com/thelounge/lounge/pull/810) by [@xPaw](https://github.com/xPaw))
- Store channel keys ([#1003](https://github.com/thelounge/lounge/pull/1003) by [@xPaw](https://github.com/xPaw), [#715](https://github.com/thelounge/lounge/pull/715) by [@spookhurb](https://github.com/spookhurb))
- Implement <kbd>pgup</kbd>/<kbd>pgdown</kbd> keys ([#955](https://github.com/thelounge/lounge/pull/955) by [@xPaw](https://github.com/xPaw), [#1078](https://github.com/thelounge/lounge/pull/1078) by [@YaManicKill](https://github.com/YaManicKill))
- Add CSS tooltips on time elements to give ability to view time on mobile ([#824](https://github.com/thelounge/lounge/pull/824) by [@xPaw](https://github.com/xPaw))
- Add SSL CA bundle option ([#1024](https://github.com/thelounge/lounge/pull/1024) by [@metsjeesus](https://github.com/metsjeesus))
- Implement History Web API ([#575](https://github.com/thelounge/lounge/pull/575) by [@williamboman](https://github.com/williamboman), [#1080](https://github.com/thelounge/lounge/pull/1080) by [@YaManicKill](https://github.com/YaManicKill))
- Add slug with command to unhandled messages ([#816](https://github.com/thelounge/lounge/pull/816) by [@DanielOaks](https://github.com/DanielOaks), [#1044](https://github.com/thelounge/lounge/pull/1044) by [@YaManicKill](https://github.com/YaManicKill))
- Add support for the `/banlist` command ([#1009](https://github.com/thelounge/lounge/pull/1009) by [@YaManicKill](https://github.com/YaManicKill))
- Add support for `/ban` and `/unban` commands ([#1077](https://github.com/thelounge/lounge/pull/1077) by [@YaManicKill](https://github.com/YaManicKill))
- Add autocompletion for emoji, users, channels, and commands ([#787](https://github.com/thelounge/lounge/pull/787) by [@yashsriv](https://github.com/yashsriv), [#1138](https://github.com/thelounge/lounge/pull/1138), [#1095](https://github.com/thelounge/lounge/pull/1095) by [@xPaw](https://github.com/xPaw))
- Add autocomplete strategy for foreground and background colors ([#1109](https://github.com/thelounge/lounge/pull/1109) by [@astorije](https://github.com/astorije))
- Add support for `0x04` hex colors ([#1100](https://github.com/thelounge/lounge/pull/1100) by [@xPaw](https://github.com/xPaw))
### Changed
- Remove table layout for chat messages (and fix layout issues yet again) ([#523](https://github.com/thelounge/lounge/pull/523) by [@maxpoulin64](https://github.com/maxpoulin64))
- Improve inline previews for links and images ([#524](https://github.com/thelounge/lounge/pull/524) by [@maxpoulin64](https://github.com/maxpoulin64))
- Use local variables to check length ([#1028](https://github.com/thelounge/lounge/pull/1028) by [@xPaw](https://github.com/xPaw))
- Add `rel="noopener"` to URLs in `index.html` and replace mIRC colors URL to [@DanielOaks](https://github.com/DanielOaks)'s [documentation](https://modern.ircdocs.horse/formatting.html#colors) ([#1034](https://github.com/thelounge/lounge/pull/1034) by [@xPaw](https://github.com/xPaw), [#1051](https://github.com/thelounge/lounge/pull/1051) by [@astorije](https://github.com/astorije))
- Preload scripts as soon as possible ([#1033](https://github.com/thelounge/lounge/pull/1033) by [@xPaw](https://github.com/xPaw))
- Improve channels list ([#1018](https://github.com/thelounge/lounge/pull/1018) by [@swordbeta](https://github.com/swordbeta))
- Show MOTD by default ([#1052](https://github.com/thelounge/lounge/pull/1052) by [@KlipperKyle](https://github.com/KlipperKyle), [#1157](https://github.com/thelounge/lounge/pull/1157) by [@astorije](https://github.com/astorije))
- Switch to a new IRC message parser ([#972](https://github.com/thelounge/lounge/pull/972) by [@xPaw](https://github.com/xPaw), [#699](https://github.com/thelounge/lounge/pull/699) by [@Bonuspunkt](https://github.com/Bonuspunkt))
- Use moment on the client to display friendly dates ([#1054](https://github.com/thelounge/lounge/pull/1054) by [@astorije](https://github.com/astorije))
- Implement fuzzy-matching for the user list ([#856](https://github.com/thelounge/lounge/pull/856), [#1093](https://github.com/thelounge/lounge/pull/1093), [#1167](https://github.com/thelounge/lounge/pull/1167) by [@astorije](https://github.com/astorije), [#1091](https://github.com/thelounge/lounge/pull/1091) by [@PolarizedIons](https://github.com/PolarizedIons), [#1107](https://github.com/thelounge/lounge/pull/1107) by [@xPaw](https://github.com/xPaw))
- Use moment to render dates everywhere ([#1114](https://github.com/thelounge/lounge/pull/1114) by [@xPaw](https://github.com/xPaw))
- Update production dependencies to their latest versions, by [Greenkeeper](https://greenkeeper.io/) 🚀:
- `moment` ([#976](https://github.com/thelounge/lounge/pull/976), [#999](https://github.com/thelounge/lounge/pull/999))
- `fs-extra` ([#964](https://github.com/thelounge/lounge/pull/964), [#1098](https://github.com/thelounge/lounge/pull/1098), [#1136](https://github.com/thelounge/lounge/pull/1136))
- `jquery` ([#969](https://github.com/thelounge/lounge/pull/969), [#998](https://github.com/thelounge/lounge/pull/998))
- `urijs` ([#995](https://github.com/thelounge/lounge/pull/995))
- `mousetrap` ([#1006](https://github.com/thelounge/lounge/pull/1006))
- `irc-framework` ([#1070](https://github.com/thelounge/lounge/pull/1070), [#1074](https://github.com/thelounge/lounge/pull/1074), [#1123](https://github.com/thelounge/lounge/pull/1123))
- `handlebars` ([#1116](https://github.com/thelounge/lounge/pull/1116), [#1129](https://github.com/thelounge/lounge/pull/1129))
### Removed
- Remove invalid CSS perspective properties ([#1027](https://github.com/thelounge/lounge/pull/1027) by [@astorije](https://github.com/astorije))
- Remove cycle nicks button ([#1062](https://github.com/thelounge/lounge/pull/1062) by [@xPaw](https://github.com/xPaw))
### Fixed
- Rewrite identd server, combine with oidentd ([#804](https://github.com/thelounge/lounge/pull/804), [#970](https://github.com/thelounge/lounge/pull/970) by [@xPaw](https://github.com/xPaw))
- Fix wrong font size in help center labels ([#994](https://github.com/thelounge/lounge/pull/994) by [@astorije](https://github.com/astorije))
- Fix filling in the nickname, overriding the username in the New Network window ([#873](https://github.com/thelounge/lounge/pull/873) by [@PolarizedIons](https://github.com/PolarizedIons))
- Correctly append date marker when receiving a message ([#1002](https://github.com/thelounge/lounge/pull/1002) by [@xPaw](https://github.com/xPaw))
- Count only message items for when loading more messages ([#1013](https://github.com/thelounge/lounge/pull/1013) by [@awalgarg](https://github.com/awalgarg))
- Fix Zenburn and Morning channel list font color ([#1017](https://github.com/thelounge/lounge/pull/1017) by [@swordbeta](https://github.com/swordbeta))
- Stick to bottom when opening user list ([#1032](https://github.com/thelounge/lounge/pull/1032) by [@xPaw](https://github.com/xPaw))
- Reset notification markers on document focus ([#1040](https://github.com/thelounge/lounge/pull/1040) by [@xPaw](https://github.com/xPaw))
- Disable show more button when loading messages ([#1045](https://github.com/thelounge/lounge/pull/1045) by [@YaManicKill](https://github.com/YaManicKill))
- Fix to `helper.expandhome` to correctly resolve `""` and `undefined` ([#1050](https://github.com/thelounge/lounge/pull/1050) by [@metsjeesus](https://github.com/metsjeesus))
- Fix displayNetwork to work correctly ([#1069](https://github.com/thelounge/lounge/pull/1069) by [@xPaw](https://github.com/xPaw))
- Enable show more button correctly ([#1068](https://github.com/thelounge/lounge/pull/1068) by [@xPaw](https://github.com/xPaw))
- Rewrite server code of channel sorting ([#1064](https://github.com/thelounge/lounge/pull/1064) by [@xPaw](https://github.com/xPaw) and ([#1115](https://github.com/thelounge/lounge/pull/1115) by [@PolarizedIons](https://github.com/PolarizedIons)))
- Fix showing prefetch options ([#1087](https://github.com/thelounge/lounge/pull/1087) by [@YaManicKill](https://github.com/YaManicKill))
- Add `/ctcp` command to constants and auto-completion ([#1108](https://github.com/thelounge/lounge/pull/1108) by [@MaxLeiter](https://github.com/MaxLeiter))
- Disable `tabindex` on user list search input ([#1122](https://github.com/thelounge/lounge/pull/1122) by [@xPaw](https://github.com/xPaw))
- Fix date-marker not being removed on loading new messages ([#1132](https://github.com/thelounge/lounge/pull/1132), [#1156](https://github.com/thelounge/lounge/pull/1156) by [@PolarizedIons](https://github.com/PolarizedIons))
### Security
- Switch to `bcryptjs` and make password comparison asynchronous ([#985](https://github.com/thelounge/lounge/pull/985) by [@rockhouse](https://github.com/rockhouse), [`b46f92c`](https://github.com/thelounge/lounge/commit/b46f92c7d8a07e84f49a550b32204c0a0672e831) by [@xPaw](https://github.com/xPaw))
- Use Referrer-Policy header instead of CSP referrer ([#1015](https://github.com/thelounge/lounge/pull/1015) by [@astorije](https://github.com/astorije))
### Internals
- Enforce more space and new line rules ([#975](https://github.com/thelounge/lounge/pull/975) by [@xPaw](https://github.com/xPaw))
- Setup ESLint to make sure an EOF feed is always present ([#991](https://github.com/thelounge/lounge/pull/991) by [@astorije](https://github.com/astorije))
- Do not build json3 module with Webpack ([#977](https://github.com/thelounge/lounge/pull/977) by [@xPaw](https://github.com/xPaw))
- Remove extra newline to please ESLint ([#997](https://github.com/thelounge/lounge/pull/997) by [@astorije](https://github.com/astorije))
- Use `require()` instead of import in client code ([#973](https://github.com/thelounge/lounge/pull/973) by [@xPaw](https://github.com/xPaw))
- Do not build feature branch with open pull requests on AppVeyor ([`934400f`](https://github.com/thelounge/lounge/commit/934400f5ee094e61c62dd0304cb55ea9f9666078) by [@xPaw](https://github.com/xPaw))
- Exclude Webpack config from coverage report ([#1053](https://github.com/thelounge/lounge/pull/1053) by [@astorije](https://github.com/astorije))
- Create socket module ([#1060](https://github.com/thelounge/lounge/pull/1060) by [@YaManicKill](https://github.com/YaManicKill))
- Change index.html to be rendered using handlebars ([#1057](https://github.com/thelounge/lounge/pull/1057) by [@YaManicKill](https://github.com/YaManicKill))
- Move commands into constants module ([#1067](https://github.com/thelounge/lounge/pull/1067) by [@YaManicKill](https://github.com/YaManicKill))
- Use `babel-preset-env` ([#1072](https://github.com/thelounge/lounge/pull/1072) by [@xPaw](https://github.com/xPaw))
- Use `irc-framework`'s `setTopic()` for topic command ([#1082](https://github.com/thelounge/lounge/pull/1082) by [@MaxLeiter](https://github.com/MaxLeiter))
- Create options module ([#1066](https://github.com/thelounge/lounge/pull/1066) by [@YaManicKill](https://github.com/YaManicKill))
- Update development dependencies to their latest versions, by [Greenkeeper](https://greenkeeper.io/) 🚀:
- `babel-core` ([#958](https://github.com/thelounge/lounge/pull/958), [#1021](https://github.com/thelounge/lounge/pull/1021))
- `babel-loader` ([#968](https://github.com/thelounge/lounge/pull/968), [#1020](https://github.com/thelounge/lounge/pull/1020), [#1063](https://github.com/thelounge/lounge/pull/1063))
- `babel-preset-es2015` ([#960](https://github.com/thelounge/lounge/pull/960))
- `eslint` ([#971](https://github.com/thelounge/lounge/pull/971), [#1000](https://github.com/thelounge/lounge/pull/1000))
- `nyc` ([#989](https://github.com/thelounge/lounge/pull/989), [#1113](https://github.com/thelounge/lounge/pull/1113), [#1140](https://github.com/thelounge/lounge/pull/1140))
- `webpack` ([#981](https://github.com/thelounge/lounge/pull/981), [#1007](https://github.com/thelounge/lounge/pull/1007), [#1030](https://github.com/thelounge/lounge/pull/1030), [#1133](https://github.com/thelounge/lounge/pull/1133), [#1142](https://github.com/thelounge/lounge/pull/1142))
- `stylelint` ([#1004](https://github.com/thelounge/lounge/pull/1004), [#1005](https://github.com/thelounge/lounge/pull/1005))
- `handlebars-loader` ([#1058](https://github.com/thelounge/lounge/pull/1058))
- `mocha` ([#1079](https://github.com/thelounge/lounge/pull/1079))
## v2.3.0-rc.2 - 2017-05-16 [Pre-release]
[See the full changelog](https://github.com/thelounge/lounge/compare/v2.3.0-rc.1...v2.3.0-rc.2)
This is a release candidate for v2.3.0 to ensure maximum stability for public release.
Please refer to the commit list given above for a complete list of changes, or wait for the stable release to get a thoroughly prepared change log entry.
As with all pre-releases, this version requires explicit use of the `next` tag to be installed:
```sh
npm install -g thelounge@next
```
## v2.3.0-rc.1 - 2017-05-07 [Pre-release]
[See the full changelog](https://github.com/thelounge/lounge/compare/v2.2.2...v2.3.0-rc.1)
This is a release candidate for v2.3.0 to ensure maximum stability for public release.
Please refer to the commit list given above for a complete list of changes, or wait for the stable release to get a thoroughly prepared change log entry.
As with all pre-releases, this version requires explicit use of the `next` tag to be installed:
```sh
npm install -g thelounge@next
```
## v2.2.2 - 2017-03-13 ## v2.2.2 - 2017-03-13
For more details, [see the full changelog](https://github.com/thelounge/lounge/compare/v2.2.1...v2.2.2) and [milestone](https://github.com/thelounge/lounge/milestone/11). For more details, [see the full changelog](https://github.com/thelounge/lounge/compare/v2.2.1...v2.2.2) and [milestone](https://github.com/thelounge/lounge/milestone/11?closed=1).
This patch release brings a lot of dependency upgrades and a few fixes. Passing options to the `lounge` CLI (`lounge start --port 8080`, etc.) now works as expected without requiring `--`. We have also disabled ping timeouts for now to hopefully fix automatic reconnection. Finally, upgrading `irc-framework` allows us to fix an extra couple of bugs. This patch release brings a lot of dependency upgrades and a few fixes. Passing options to the `lounge` CLI (`lounge start --port 8080`, etc.) now works as expected without requiring `--`. We have also disabled ping timeouts for now to hopefully fix automatic reconnection. Finally, upgrading `irc-framework` allows us to fix an extra couple of bugs.
@ -84,7 +452,7 @@ On the website:
## v2.2.1 - 2017-02-12 ## v2.2.1 - 2017-02-12
For more details, [see the full changelog](https://github.com/thelounge/lounge/compare/v2.2.0...v2.2.1) and [milestone](https://github.com/thelounge/lounge/milestone/10). For more details, [see the full changelog](https://github.com/thelounge/lounge/compare/v2.2.0...v2.2.1) and [milestone](https://github.com/thelounge/lounge/milestone/10?closed=1).
This patch release packs up a change of the default value of `maxHistory`, an interactive prompt when creating a user to enable/disable user logging, a UI bug fix, and a few dependency upgrades. This patch release packs up a change of the default value of `maxHistory`, an interactive prompt when creating a user to enable/disable user logging, a UI bug fix, and a few dependency upgrades.
@ -121,7 +489,7 @@ On the website:
## v2.2.0 - 2017-01-31 ## v2.2.0 - 2017-01-31
For more details, [see the full changelog](https://github.com/thelounge/lounge/compare/v2.1.0...v2.2.0) and [milestone](https://github.com/thelounge/lounge/milestone/2). For more details, [see the full changelog](https://github.com/thelounge/lounge/compare/v2.1.0...v2.2.0) and [milestone](https://github.com/thelounge/lounge/milestone/2?closed=1).
Another long-overdue release for The Lounge! Another long-overdue release for The Lounge!

View File

@ -3,12 +3,6 @@
Welcome to The Lounge, it's great to have you here! We thank you in advance for Welcome to The Lounge, it's great to have you here! We thank you in advance for
your contributions. your contributions.
### I have a question
- Find us on the Freenode channel `#thelounge`. You might not get an answer
right away, but this channel is full of nice people who will be happy to
help you.
### I want to report a bug ### I want to report a bug
- Look at the [open and closed - Look at the [open and closed

View File

@ -1,18 +1,25 @@
# The Lounge # The Lounge
[![#thelounge IRC channel on freenode](https://img.shields.io/badge/freenode-%23thelounge-BA68C8.svg)](https://avatar.playat.ch:1000/) Modern web IRC client designed for self-hosting.
[![#thelounge IRC channel on freenode](https://img.shields.io/badge/freenode-%23thelounge-BA68C8.svg)](https://demo.thelounge.chat/)
[![npm version](https://img.shields.io/npm/v/thelounge.svg)](https://www.npmjs.org/package/thelounge) [![npm version](https://img.shields.io/npm/v/thelounge.svg)](https://www.npmjs.org/package/thelounge)
[![Travis CI Build Status](https://img.shields.io/travis/thelounge/lounge/master.svg?label=linux+build)](https://travis-ci.org/thelounge/lounge) [![Travis CI Build Status](https://img.shields.io/travis/thelounge/lounge/master.svg?label=linux+build)](https://travis-ci.org/thelounge/lounge)
[![AppVeyor Build Status](https://img.shields.io/appveyor/ci/astorije/lounge/master.svg?label=windows+build)](https://ci.appveyor.com/project/astorije/lounge/branch/master) [![AppVeyor Build Status](https://img.shields.io/appveyor/ci/astorije/lounge/master.svg?label=windows+build)](https://ci.appveyor.com/project/astorije/lounge/branch/master)
[![Dependencies Status](https://img.shields.io/david/thelounge/lounge.svg)](https://david-dm.org/thelounge/lounge) [![Dependencies Status](https://img.shields.io/david/thelounge/lounge.svg)](https://david-dm.org/thelounge/lounge)
[![Developer Dependencies Status](https://img.shields.io/david/dev/thelounge/lounge.svg)](https://david-dm.org/thelounge/lounge?type=dev)
The Lounge is a modern web IRC client designed for self-hosting. ## Overview
* **Modern features brought to IRC.** Push notifications, link previews, new message markers, and more bring IRC to the 21st century.
* **Always connected.** Remains connected to IRC servers while you are offline.
* **Cross platform.** It doesn't matter what OS you use, it just works wherever Node.js runs.
* **Responsive interface.** The client works smoothly on every desktop, smartphone and tablet.
* **Synchronized experience.** Always resume where you left off no matter what device.
To learn more about configuration, usage and features of The Lounge, take a look at [the website](https://thelounge.github.io). To learn more about configuration, usage and features of The Lounge, take a look at [the website](https://thelounge.github.io).
<p align="center"> <p align="center">
<img src="https://cloud.githubusercontent.com/assets/5481612/19623041/9bbaec40-9888-11e6-9961-8f3e0493ba30.png" width="550"> <img src="https://user-images.githubusercontent.com/8675906/28143204-53116e8c-6719-11e7-992b-d1ba442c6c37.png" width="550">
</p> </p>
The Lounge is the official and community-managed fork of [Shout](https://github.com/erming/shout), by [Mattias Erming](https://github.com/erming). The Lounge is the official and community-managed fork of [Shout](https://github.com/erming/shout), by [Mattias Erming](https://github.com/erming).
@ -68,4 +75,4 @@ Before submitting any change, make sure to:
- Read the [Contributing instructions](https://github.com/thelounge/lounge/blob/master/CONTRIBUTING.md#contributing) - Read the [Contributing instructions](https://github.com/thelounge/lounge/blob/master/CONTRIBUTING.md#contributing)
- Run `npm test` to execute linters and test suite - Run `npm test` to execute linters and test suite
- Run `npm run build` if you change or add anything in `client/js/libs` or `client/views` - Run `npm run build` if you change or add anything in `client/js` or `client/views`

11
SUPPORT.md Normal file
View File

@ -0,0 +1,11 @@
## Support
Welcome to The Lounge, it's great to have you here! If you have a question, or
need help, you have a few options:
- Check out [existing questions on Stack Overflow](https://stackoverflow.com/questions/tagged/thelounge)
to see if yours has been answered before. If not, feel free to [ask for a new question](https://stackoverflow.com/questions/ask?tags=thelounge)
(using `thelounge` tag so that other people can easily find it).
- Find us on the Freenode channel `#thelounge`. You might not get an answer
right away, but this channel is full of nice people who will be happy to
help you.

View File

@ -7,6 +7,9 @@ version: "{build}"
# Do not build on tags (GitHub only) # Do not build on tags (GitHub only)
skip_tags: true skip_tags: true
# Do not build feature branch with open pull requests
skip_branch_with_pr: true
environment: environment:
nodejs_version: '4' nodejs_version: '4'

View File

@ -1,3 +0,0 @@
---
parserOptions:
sourceType: module

File diff suppressed because it is too large Load Diff

View File

@ -4,26 +4,28 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, user-scalable=no"> <meta name="viewport" content="width=device-width, user-scalable=no">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="apple-mobile-web-app-capable" content="yes"> <link rel="preload" as="script" href="js/bundle.vendor.js">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <link rel="preload" as="script" href="js/bundle.js">
<meta name="mobile-web-app-capable" content="yes"> <link rel="stylesheet" href="css/bootstrap.css">
<meta name="referrer" content="no-referrer"> <link rel="stylesheet" href="css/style.css">
<meta name="theme-color" content="#455164"> <link id="theme" rel="stylesheet" href="{{ theme }}">
<style id="user-specified-css"></style>
<title>The Lounge</title> <title>The Lounge</title>
<link rel="stylesheet" href="css/bootstrap.css">
<link rel="stylesheet" href="css/style.css">
<link id="theme" rel="stylesheet" href="<%= theme %>">
<style id="user-specified-css"></style>
<link rel="shortcut icon" href="img/favicon.png" data-other="img/favicon-notification.png" data-toggled="false" id="favicon"> <link rel="shortcut icon" href="img/favicon.png" data-other="img/favicon-notification.png" data-toggled="false" id="favicon">
<link rel="apple-touch-icon" sizes="120x120" href="img/apple-touch-icon-120x120.png"> <link rel="apple-touch-icon" sizes="120x120" href="img/apple-touch-icon-120x120.png">
<link rel="mask-icon" href="img/logo.svg" color="#455164">
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#455164">
</head> </head>
<body class="signed-out <%= public ? "public" : "" %>"> <body class="signed-out {{#if public}}public{{/if}}" data-transports="{{tojson transports}}">
<div id="wrap"> <div id="wrap">
<div id="viewport"> <div id="viewport">
@ -34,11 +36,11 @@
</div> </div>
</aside> </aside>
<footer id="footer"> <footer id="footer">
<span class="tooltipped tooltipped-n" aria-label="Sign in"><button class="icon sign-in" data-target="#sign-in" aria-label="Sign in"></button></span> <span class="tooltipped tooltipped-n tooltipped-no-touch" aria-label="Sign in"><button class="icon sign-in" data-target="#sign-in" aria-label="Sign in"></button></span>
<span class="tooltipped tooltipped-n" aria-label="Connect to network"><button class="icon connect" data-target="#connect" aria-label="Connect to network"></button></span> <span class="tooltipped tooltipped-n tooltipped-no-touch" aria-label="Connect to network"><button class="icon connect" data-target="#connect" aria-label="Connect to network"></button></span>
<span class="tooltipped tooltipped-n" aria-label="Client settings"><button class="icon settings" data-target="#settings" aria-label="Client settings"></button></span> <span class="tooltipped tooltipped-n tooltipped-no-touch" aria-label="Client settings"><button class="icon settings" data-target="#settings" aria-label="Client settings"></button></span>
<span class="tooltipped tooltipped-n" aria-label="Help"><button class="icon help" data-target="#help" aria-label="Help"></button></span> <span class="tooltipped tooltipped-n tooltipped-no-touch" aria-label="Help"><button class="icon help" data-target="#help" aria-label="Help"></button></span>
<span class="tooltipped tooltipped-n" aria-label="Sign out"><button class="icon sign-out" id="sign-out" aria-label="Sign out"></button></span> <span class="tooltipped tooltipped-n tooltipped-no-touch" aria-label="Sign out"><button class="icon sign-out" id="sign-out" aria-label="Sign out"></button></span>
</footer> </footer>
<div id="main"> <div id="main">
<div id="windows"> <div id="windows">
@ -49,8 +51,11 @@
<h1 class="title">The Lounge is loading…</h1> <h1 class="title">The Lounge is loading…</h1>
</div> </div>
<div class="col-xs-12"> <div class="col-xs-12">
<p id="loading-page-message">Loading the app… <a href="http://enable-javascript.com/" target="_blank">Make sure to have JavaScript enabled.</a></p> <p id="loading-page-message">Loading the app… <a href="http://enable-javascript.com/" target="_blank" rel="noopener">Make sure to have JavaScript enabled.</a></p>
<p id="loading-slow">This is taking longer than it should, there might be connectivity issues.</p> <div id="loading-slow">
<p>This is taking longer than it should, there might be connectivity issues.</p>
<button id="loading-slow-reload" class="btn">Reload page</button>
</div>
<script async src="js/loading-slow-alert.js"></script> <script async src="js/loading-slow-alert.js"></script>
</div> </div>
</div> </div>
@ -68,10 +73,7 @@
--><span id="save-nick-tooltip" class="tooltipped tooltipped-e" aria-label="Save"><button id="submit-nick" type="button" aria-label="Save"></button></span> --><span id="save-nick-tooltip" class="tooltipped tooltipped-e" aria-label="Save"><button id="submit-nick" type="button" aria-label="Save"></button></span>
</span> </span>
<textarea id="input" class="mousetrap"></textarea> <textarea id="input" class="mousetrap"></textarea>
<span id="cycle-nicks-tooltip" class="tooltipped tooltipped-w" aria-label="Cycle through nicks"> <span id="submit-tooltip" class="tooltipped tooltipped-w tooltipped-no-touch" aria-label="Send message">
<button id="cycle-nicks" type="button" aria-label="Cycle through nicks"></button>
</span>
<span id="submit-tooltip" class="tooltipped tooltipped-w" aria-label="Send message">
<button id="submit" type="submit" aria-label="Send message"></button> <button id="submit" type="submit" aria-label="Send message"></button>
</span> </span>
</div> </div>
@ -97,7 +99,7 @@
</div> </div>
<div class="col-xs-12"> <div class="col-xs-12">
<label class="remember"> <label class="remember">
<input type="checkbox" name="remember" id="sign-in-remember" checked> <input type="checkbox" name="remember" value="on" id="sign-in-remember" checked>
Stay signed in Stay signed in
</label> </label>
</div> </div>
@ -120,12 +122,17 @@
<div class="row"> <div class="row">
<div class="col-sm-12"> <div class="col-sm-12">
<h1 class="title"> <h1 class="title">
<%= public ? "The Lounge - " : "" %> {{#if public}}The Lounge - {{/if}}
Connect Connect
<%= !displayNetwork && lockNetwork ? "to " + defaults.name : "" %> {{#unless displayNetwork}}
{{#if lockNetwork}}
to {{defaults.name}}
{{/if}}
{{/unless}}
</h1> </h1>
</div> </div>
<div <%= typeof(displayNetwork) !== "undefined" && !displayNetwork ? 'style="display: none;"' : ''%>> {{#if displayNetwork}}
<div>
<div class="col-sm-12"> <div class="col-sm-12">
<h2>Network settings</h2> <h2>Network settings</h2>
</div> </div>
@ -133,17 +140,17 @@
<label for="connect:name">Name</label> <label for="connect:name">Name</label>
</div> </div>
<div class="col-sm-9"> <div class="col-sm-9">
<input class="input" id="connect:name" name="name" value="<%= defaults.name %>"> <input class="input" id="connect:name" name="name" value="{{defaults.name}}">
</div> </div>
<div class="col-sm-3"> <div class="col-sm-3">
<label for="connect:host">Server</label> <label for="connect:host">Server</label>
</div> </div>
<div class="col-sm-6 col-xs-8"> <div class="col-sm-6 col-xs-8">
<input class="input" id="connect:host" name="host" value="<%= defaults.host %>" aria-label="Server address" <%= typeof(lockNetwork) !== "undefined" && lockNetwork ? "disabled" : "" %>> <input class="input" id="connect:host" name="host" value="{{defaults.host}}" aria-label="Server address" {{#if lockNetwork}}disabled{{/if}}>
</div> </div>
<div class="col-sm-3 col-xs-4"> <div class="col-sm-3 col-xs-4">
<div class="port"> <div class="port">
<input class="input" type="number" min="1" max="65535" name="port" value="<%= defaults.port %>" aria-label="Server port" <%= typeof(lockNetwork) !== "undefined" && lockNetwork ? "disabled" : "" %>> <input class="input" type="number" min="1" max="65535" name="port" value="{{defaults.port}}" aria-label="Server port" {{#if lockNetwork}}disabled{{/if}}>
</div> </div>
</div> </div>
<div class="clearfix"></div> <div class="clearfix"></div>
@ -151,16 +158,17 @@
<label for="connect:password">Password</label> <label for="connect:password">Password</label>
</div> </div>
<div class="col-sm-9"> <div class="col-sm-9">
<input class="input" id="connect:password" type="password" name="password" value="<%= defaults.password %>"> <input class="input" id="connect:password" type="password" name="password" value="{{defaults.password}}">
</div> </div>
<div class="col-sm-9 col-sm-offset-3"> <div class="col-sm-9 col-sm-offset-3">
<label class="tls"> <label class="tls">
<input type="checkbox" name="tls" <%= defaults.tls ? "checked" : "" %> <%= typeof(lockNetwork) !== "undefined" && lockNetwork ? "disabled" : "" %>> <input type="checkbox" name="tls" {{#if defaults.tls}}checked{{/if}} {{#if lockNetwork}}disabled{{/if}}>
Enable TLS/SSL Enable TLS/SSL
</label> </label>
</div> </div>
<div class="clearfix"></div> <div class="clearfix"></div>
</div> </div>
{{/if}}
<div class="col-sm-12"> <div class="col-sm-12">
<h2>User preferences</h2> <h2>User preferences</h2>
</div> </div>
@ -168,27 +176,27 @@
<label for="connect:nick">Nick</label> <label for="connect:nick">Nick</label>
</div> </div>
<div class="col-sm-9"> <div class="col-sm-9">
<input class="input nick" id="connect:nick" name="nick" value="<%= defaults.nick %>"> <input class="input nick" id="connect:nick" name="nick" value="{{defaults.nick}}">
</div> </div>
<% if (!useHexIp) { %> {{#unless useHexIp}}
<div class="col-sm-3"> <div class="col-sm-3">
<label for="connect:username">Username</label> <label for="connect:username">Username</label>
</div> </div>
<div class="col-sm-9"> <div class="col-sm-9">
<input class="input username" id="connect:username" name="username" value="<%= defaults.username %>"> <input class="input username" id="connect:username" name="username" value="{{defaults.username}}">
</div> </div>
<% } %> {{/unless}}
<div class="col-sm-3"> <div class="col-sm-3">
<label for="connect:realname">Real name</label> <label for="connect:realname">Real name</label>
</div> </div>
<div class="col-sm-9"> <div class="col-sm-9">
<input class="input" id="connect:realname" name="realname" value="<%= defaults.realname %>"> <input class="input" id="connect:realname" name="realname" value="{{defaults.realname}}">
</div> </div>
<div class="col-sm-3"> <div class="col-sm-3">
<label for="connect:channels">Channels</label> <label for="connect:channels">Channels</label>
</div> </div>
<div class="col-sm-9"> <div class="col-sm-9">
<input class="input" id="connect:channels" name="join" value="<%= defaults.join %>"> <input class="input" id="connect:channels" name="join" value="{{defaults.join}}">
</div> </div>
<div class="col-sm-9 col-sm-offset-3"> <div class="col-sm-9 col-sm-offset-3">
<button type="submit" class="btn">Connect</button> <button type="submit" class="btn">Connect</button>
@ -208,12 +216,6 @@
<div class="col-sm-12"> <div class="col-sm-12">
<h2>Messages</h2> <h2>Messages</h2>
</div> </div>
<div class="col-sm-6">
<label class="opt">
<input type="checkbox" name="join">
Show joins
</label>
</div>
<div class="col-sm-6"> <div class="col-sm-6">
<label class="opt"> <label class="opt">
<input type="checkbox" name="motd"> <input type="checkbox" name="motd">
@ -222,26 +224,30 @@
</div> </div>
<div class="col-sm-6"> <div class="col-sm-6">
<label class="opt"> <label class="opt">
<input type="checkbox" name="part"> <input type="checkbox" name="showSeconds">
Show parts Show seconds in timestamp
</label> </label>
</div> </div>
<div class="col-sm-6"> <div class="col-sm-12">
<label class="opt"> <h2>
<input type="checkbox" name="nick"> Status messages
Show nick changes <span class="tooltipped tooltipped-n tooltipped-no-delay" aria-label="Joins, parts, kicks, nick changes, and mode changes">
</label> <button class="extra-help" aria-label="Joins, parts, kicks, nick changes, and mode changes"></button>
</span>
</h2>
</div> </div>
<div class="col-sm-6"> <div class="col-sm-12">
<label class="opt"> <label class="opt">
<input type="checkbox" name="mode"> <input type="radio" name="statusMessages" value="shown">
Show mode Show all status messages individually
</label> </label>
</div>
<div class="col-sm-6">
<label class="opt"> <label class="opt">
<input type="checkbox" name="quit"> <input type="radio" name="statusMessages" value="condensed">
Show quits Condense status messages together
</label>
<label class="opt">
<input type="radio" name="statusMessages" value="hidden">
Hide all status messages
</label> </label>
</div> </div>
<div class="col-sm-12"> <div class="col-sm-12">
@ -252,6 +258,10 @@
<input type="checkbox" name="coloredNicks"> <input type="checkbox" name="coloredNicks">
Enable colored nicknames Enable colored nicknames
</label> </label>
<label class="opt">
<input type="checkbox" name="autocomplete">
Enable autocomplete
</label>
</div> </div>
<div class="col-sm-12"> <div class="col-sm-12">
<h2>Theme</h2> <h2>Theme</h2>
@ -259,44 +269,60 @@
<div class="col-sm-12"> <div class="col-sm-12">
<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">
<% themes.forEach(function(themeName) { %> {{#each themes}}
<option value="<%= themeName %>"> <option value="{{filename}}">
<%= themeName.charAt(0).toUpperCase() + themeName.slice(1) %> {{name}}
</option> </option>
<% }) %> {{/each}}
</select> </select>
</div> </div>
<% if (typeof prefetch === "undefined" || prefetch !== false) { %> {{#if prefetch}}
<div class="col-sm-12"> <div class="col-sm-12">
<h2>Links and URLs</h2> <h2>Link previews</h2>
</div> </div>
<div class="col-sm-6"> <div class="col-sm-6">
<label class="opt"> <label class="opt">
<input type="checkbox" name="thumbnails"> <input type="checkbox" name="thumbnails">
Auto-expand thumbnails Auto-expand images
</label> </label>
</div> </div>
<div class="col-sm-6"> <div class="col-sm-6">
<label class="opt"> <label class="opt">
<input type="checkbox" name="links"> <input type="checkbox" name="links">
Auto-expand links Auto-expand websites
</label> </label>
</div> </div>
<% } %> {{/if}}
{{#unless public}}
<div class="col-sm-12"> <div class="col-sm-12">
<h2>Notifications</h2> <h2>Push Notifications</h2>
</div>
<div class="col-sm-12">
<button type="button" class="btn" id="pushNotifications" disabled data-text-alternate="Unsubscribe from push notifications">Subscribe to push notifications</button>
<div class="error" id="pushNotificationsHttps">
<strong>Warning</strong>:
Push notifications are only supported over HTTPS connections.
</div>
<div class="error" id="pushNotificationsUnsupported">
<strong>Warning</strong>:
<span>Push notifications are not supported by your browser.</span>
</div>
</div>
{{/unless}}
<div class="col-sm-12">
<h2>Browser Notifications</h2>
</div> </div>
<div class="col-sm-12"> <div class="col-sm-12">
<label class="opt"> <label class="opt">
<input id="desktopNotifications" type="checkbox" name="desktopNotifications"> <input id="desktopNotifications" type="checkbox" name="desktopNotifications">
Enable desktop notifications<br> Enable browser notifications<br>
<div class="error" id="warnUnsupportedDesktopNotifications"> <div class="error" id="warnUnsupportedDesktopNotifications">
<strong>Warning</strong>: <strong>Warning</strong>:
Desktop notifications are not supported by your browser. Notifications are not supported by your browser.
</div> </div>
<div class="error" id="warnBlockedDesktopNotifications"> <div class="error" id="warnBlockedDesktopNotifications">
<strong>Warning</strong>: <strong>Warning</strong>:
Desktop notifications are blocked by your browser. Notifications are blocked by your browser.
</div> </div>
</label> </label>
</div> </div>
@ -326,7 +352,8 @@
</label> </label>
</div> </div>
<% if (!public && !ldap.enable) { %> {{#unless public}}
{{#unless ldap.enable}}
<div id="change-password"> <div id="change-password">
<form action="" method="post"> <form action="" method="post">
<div class="col-sm-12"> <div class="col-sm-12">
@ -350,7 +377,8 @@
</div> </div>
</form> </form>
</div> </div>
<% } %> {{/unless}}
{{/unless}}
<div class="col-sm-12"> <div class="col-sm-12">
<h2>Custom Stylesheet</h2> <h2>Custom Stylesheet</h2>
</div> </div>
@ -390,6 +418,62 @@
</div> </div>
</div> </div>
<div class="help-item">
<div class="subject">
<kbd>Ctrl</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>Ctrl</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>Ctrl</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>Ctrl</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>Ctrl</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> <h3>On macOS</h3>
<div class="help-item"> <div class="help-item">
@ -403,13 +487,69 @@
<div class="help-item"> <div class="help-item">
<div class="subject"> <div class="subject">
<kbd></kbd> + <kbd>K</kbd> <kbd></kbd> + <kbd></kbd> + <kbd>L</kbd>
</div> </div>
<div class="description"> <div class="description">
<p>Clear the current screen</p> <p>Clear the current screen</p>
</div> </div>
</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 class="description">
<p>
Mark all text typed after this shortcut to be reset to its
original formatting.
</p>
</div>
</div>
<h2>Commands</h2> <h2>Commands</h2>
<div class="help-item"> <div class="help-item">
@ -430,6 +570,25 @@
</div> </div>
</div> </div>
<div class="help-item">
<div class="subject">
<code>/ban nick</code>
</div>
<div class="description">
<p>Ban (<code>+b</code>) a user from the current channel.
This can be a nickname or a hostmask.</p>
</div>
</div>
<div class="help-item">
<div class="subject">
<code>/banlist</code>
</div>
<div class="description">
<p>Load the banlist for the current channel.</p>
</div>
</div>
<div class="help-item"> <div class="help-item">
<div class="subject"> <div class="subject">
<code>/clear</code> <code>/clear</code>
@ -439,6 +598,18 @@
</div> </div>
</div> </div>
<div class="help-item">
<div class="subject">
<code>/collapse</code>
</div>
<div class="description">
<p>
Collapse all previews in the current channel (opposite of
<code>/expand</code>)
</p>
</div>
</div>
<div class="help-item"> <div class="help-item">
<div class="subject"> <div class="subject">
<code>/connect host [port]</code> <code>/connect host [port]</code>
@ -461,7 +632,7 @@
<p> <p>
Send a <abbr title="Client-to-client protocol">CTCP</abbr> Send a <abbr title="Client-to-client protocol">CTCP</abbr>
request. Read more about this on request. Read more about this on
<a href="https://en.wikipedia.org/wiki/Client-to-client_protocol">the dedicated Wikipedia article</a>. <a href="https://en.wikipedia.org/wiki/Client-to-client_protocol" target="_blank" rel="noopener">the dedicated Wikipedia article</a>.
</p> </p>
</div> </div>
</div> </div>
@ -502,6 +673,18 @@
</div> </div>
</div> </div>
<div class="help-item">
<div class="subject">
<code>/expand</code>
</div>
<div class="description">
<p>
Expand all previews in the current channel (opposite of
<code>/collapse</code>)
</p>
</div>
</div>
<div class="help-item"> <div class="help-item">
<div class="subject"> <div class="subject">
<code>/invite nick [channel]</code> <code>/invite nick [channel]</code>
@ -666,6 +849,16 @@
</div> </div>
</div> </div>
<div class="help-item">
<div class="subject">
<code>/unban nick</code>
</div>
<div class="description">
<p>Unban (<code>-b</code>) a user from the current channel.
This can be a nickname or a hostmask.</p>
</div>
</div>
<div class="help-item"> <div class="help-item">
<div class="subject"> <div class="subject">
<code>/voice nick [...nick]</code> <code>/voice nick [...nick]</code>
@ -693,17 +886,17 @@
<h2>About The Lounge</h2> <h2>About The Lounge</h2>
<p class="about"> <p class="about">
<% if (gitCommit) { %> {{#if gitCommit}}
The Lounge is running from source The Lounge is running from source
(<a href="https://github.com/thelounge/lounge/tree/<%= gitCommit %>" target="_blank"><code><%= gitCommit %></code></a>).<br> (<a href="https://github.com/thelounge/lounge/tree/{{ gitCommit }}" target="_blank" rel="noopener"><code>{{ gitCommit }}</code></a>).<br>
<% } else { %> {{else}}
The Lounge is in version <strong><%= version %></strong> The Lounge is in version <strong>{{version}}</strong>
(<a href="https://github.com/thelounge/lounge/releases/tag/v<%= version %>" target="_blank">See release notes</a>).<br> (<a href="https://github.com/thelounge/lounge/releases/tag/v{{ version }}" target="_blank" rel="noopener">See release notes</a>).<br>
<% } %> {{/if}}
<a href="https://thelounge.github.io/" target="_blank">Website</a><br> <a href="https://thelounge.github.io/" target="_blank" rel="noopener">Website</a><br>
<a href="https://thelounge.github.io/docs/" target="_blank">Documentation</a><br> <a href="https://thelounge.github.io/docs/" target="_blank" rel="noopener">Documentation</a><br>
<a href="https://github.com/thelounge/lounge/issues/new" target="_blank">Report a bug</a> <a href="https://github.com/thelounge/lounge/issues/new" target="_blank" rel="noopener">Report a bug</a>
</p> </p>
</div> </div>
</div> </div>
@ -716,6 +909,8 @@
<ul id="context-menu"></ul> <ul id="context-menu"></ul>
</div> </div>
<div id="image-viewer"></div>
<script src="js/bundle.vendor.js"></script> <script src="js/bundle.vendor.js"></script>
<script src="js/bundle.js"></script> <script src="js/bundle.js"></script>

217
client/js/autocompletion.js Normal file
View File

@ -0,0 +1,217 @@
"use strict";
const $ = require("jquery");
const fuzzy = require("fuzzy");
const emojiMap = require("./libs/simplemap.json");
const options = require("./options");
const constants = require("./constants");
require("jquery-textcomplete");
require("./libs/jquery/tabcomplete");
const chat = $("#chat");
const sidebar = $("#sidebar");
const emojiSearchTerms = Object.keys(emojiMap);
const emojiStrategy = {
id: "emoji",
match: /\B:([-+\w:?]{2,}):?$/,
search(term, callback) {
// Trim colon from the matched term,
// as we are unable to get a clean string from match regex
term = term.replace(/:$/, ""),
callback(fuzzyGrep(term, emojiSearchTerms));
},
template([string, original]) {
return `<span class="emoji">${emojiMap[original]}</span> ${string}`;
},
replace([, original]) {
return emojiMap[original];
},
index: 1
};
const nicksStrategy = {
id: "nicks",
match: /\B(@([a-zA-Z_[\]\\^{}|`@][a-zA-Z0-9_[\]\\^{}|`-]*)?)$/,
search(term, callback) {
term = term.slice(1);
if (term[0] === "@") {
callback(completeNicks(term.slice(1), true)
.map((val) => ["@" + val[0], "@" + val[1]]));
} else {
callback(completeNicks(term, true));
}
},
template([string, ]) {
return string;
},
replace([, original]) {
return original;
},
index: 1
};
const chanStrategy = {
id: "chans",
match: /\B((#|\+|&|![A-Z0-9]{5})([^\x00\x0A\x0D\x20\x2C\x3A]+(:[^\x00\x0A\x0D\x20\x2C\x3A]*)?)?)$/,
search(term, callback, match) {
callback(completeChans(match[0]));
},
template([string,]) {
return string;
},
replace([, original]) {
return original;
},
index: 1
};
const commandStrategy = {
id: "commands",
match: /^\/(\w*)$/,
search(term, callback) {
callback(completeCommands("/" + term));
},
template([string, ]) {
return string;
},
replace([, original]) {
return original;
},
index: 1
};
const foregroundColorStrategy = {
id: "foreground-colors",
match: /\x03(\d{0,2}|[A-Za-z ]{0,10})$/,
search(term, callback) {
term = term.toLowerCase();
const matchingColorCodes = constants.colorCodeMap
.filter((i) => fuzzy.test(term, i[0]) || fuzzy.test(term, i[1]))
.map((i) => {
if (fuzzy.test(term, i[1])) {
return [i[0], fuzzy.match(term, i[1], {
pre: "<b>",
post: "</b>"
}).rendered];
}
return i;
});
callback(matchingColorCodes);
},
template(value) {
return `<span class="irc-fg${parseInt(value[0], 10)}">${value[1]}</span>`;
},
replace(value) {
return "\x03" + value[0];
},
index: 1
};
const backgroundColorStrategy = {
id: "background-colors",
match: /\x03(\d{2}),(\d{0,2}|[A-Za-z ]{0,10})$/,
search(term, callback, match) {
term = term.toLowerCase();
const matchingColorCodes = constants.colorCodeMap
.filter((i) => fuzzy.test(term, i[0]) || fuzzy.test(term, i[1]))
.map((pair) => {
if (fuzzy.test(term, pair[1])) {
return [pair[0], fuzzy.match(term, pair[1], {
pre: "<b>",
post: "</b>"
}).rendered];
}
return pair;
})
.map((pair) => pair.concat(match[1])); // Needed to pass fg color to `template`...
callback(matchingColorCodes);
},
template(value) {
return `<span class="irc-fg${parseInt(value[2], 10)} irc-bg irc-bg${parseInt(value[0], 10)}">${value[1]}</span>`;
},
replace(value) {
return "\x03$1," + value[0];
},
index: 2
};
const input = $("#input")
.tab((word) => completeNicks(word, false), {hint: false})
.on("autocomplete:on", function() {
enableAutocomplete();
});
if (options.autocomplete) {
enableAutocomplete();
}
function enableAutocomplete() {
input.textcomplete([
emojiStrategy, nicksStrategy, chanStrategy, commandStrategy,
foregroundColorStrategy, backgroundColorStrategy
], {
dropdownClassName: "textcomplete-menu",
placement: "top"
}).on({
"textComplete:show": function() {
$(this).data("autocompleting", true);
},
"textComplete:hide": function() {
$(this).data("autocompleting", false);
}
});
}
function fuzzyGrep(term, array) {
const results = fuzzy.filter(
term,
array,
{
pre: "<b>",
post: "</b>"
}
);
return results.map((el) => [el.string, el.original]);
}
function completeNicks(word, isFuzzy) {
const users = chat.find(".active .users");
word = word.toLowerCase();
// Lobbies and private chats do not have an user list
if (!users.length) {
return [];
}
const words = users.data("nicks");
if (isFuzzy) {
return fuzzyGrep(word, words);
}
return $.grep(
words,
(w) => !w.toLowerCase().indexOf(word)
);
}
function completeCommands(word) {
const words = constants.commands.slice();
return fuzzyGrep(word, words);
}
function completeChans(word) {
const words = [];
sidebar.find(".chan")
.each(function() {
const self = $(this);
if (!self.hasClass("lobby")) {
words.push(self.data("title"));
}
});
return fuzzyGrep(word, words);
}

55
client/js/condensed.js Normal file
View File

@ -0,0 +1,55 @@
"use strict";
const constants = require("./constants");
const templates = require("../views");
module.exports = {
updateText
};
function updateText(condensed, addedTypes) {
const obj = {};
constants.condensedTypes.forEach((type) => {
obj[type] = condensed.data(type) || 0;
});
addedTypes.forEach((type) => {
obj[type]++;
condensed.data(type, obj[type]);
});
const strings = [];
constants.condensedTypes.forEach((type) => {
if (obj[type]) {
switch (type) {
case "join":
strings.push(obj[type] + (obj[type] > 1 ? " users have joined the channel" : " user has joined the channel"));
break;
case "part":
strings.push(obj[type] + (obj[type] > 1 ? " users have left the channel" : " user has left the channel"));
break;
case "quit":
strings.push(obj[type] + (obj[type] > 1 ? " users have quit" : " user has quit"));
break;
case "nick":
strings.push(obj[type] + (obj[type] > 1 ? " users have changed nick" : " user has changed nick"));
break;
case "kick":
strings.push(obj[type] + (obj[type] > 1 ? " users were kicked" : " user was kicked"));
break;
case "mode":
strings.push(obj[type] + (obj[type] > 1 ? " modes were set" : " mode was set"));
break;
}
}
});
let text = strings.pop();
if (strings.length) {
text = strings.join(", ") + ", and " + text;
}
condensed.find(".condensed-text")
.html(text + templates.msg_condensed_toggle());
}

97
client/js/constants.js Normal file
View File

@ -0,0 +1,97 @@
"use strict";
const colorCodeMap = [
["00", "White"],
["01", "Black"],
["02", "Blue"],
["03", "Green"],
["04", "Red"],
["05", "Brown"],
["06", "Magenta"],
["07", "Orange"],
["08", "Yellow"],
["09", "Light Green"],
["10", "Cyan"],
["11", "Light Cyan"],
["12", "Light Blue"],
["13", "Pink"],
["14", "Grey"],
["15", "Light Grey"],
];
const commands = [
"/away",
"/back",
"/ban",
"/banlist",
"/close",
"/collapse",
"/connect",
"/ctcp",
"/deop",
"/devoice",
"/disconnect",
"/expand",
"/invite",
"/join",
"/kick",
"/leave",
"/me",
"/mode",
"/msg",
"/nick",
"/notice",
"/op",
"/part",
"/query",
"/quit",
"/raw",
"/say",
"/send",
"/server",
"/slap",
"/topic",
"/unban",
"/voice",
"/whois"
];
const actionTypes = [
"ban_list",
"invite",
"join",
"mode",
"kick",
"nick",
"part",
"quit",
"topic",
"topic_set_by",
"action",
"whois",
"ctcp",
"channel_list",
];
const condensedTypes = [
"join",
"part",
"quit",
"nick",
"kick",
"mode",
];
const timeFormats = {
msgDefault: "HH:mm",
msgWithSeconds: "HH:mm:ss"
};
module.exports = {
colorCodeMap: colorCodeMap,
commands: commands,
condensedTypes: condensedTypes,
condensedTypesQuery: "." + condensedTypes.join(", ."),
actionTypes: actionTypes,
timeFormats: timeFormats
};

View File

@ -1,3 +1,5 @@
"use strict";
// Generates a string from "color-1" to "color-32" based on an input string // Generates a string from "color-1" to "color-32" based on an input string
module.exports = function(str) { module.exports = function(str) {
var hash = 0; var hash = 0;

View File

@ -1,3 +1,5 @@
"use strict";
var diff; var diff;
module.exports = function(a, opt) { module.exports = function(a, opt) {

View File

@ -1,3 +1,5 @@
"use strict";
module.exports = function(a, b, opt) { module.exports = function(a, b, opt) {
a = a.toString(); a = a.toString();
b = b.toString(); b = b.toString();

View File

@ -0,0 +1,13 @@
"use strict";
const moment = require("moment");
module.exports = function(time) {
// See http://momentjs.com/docs/#/displaying/calendar-time/
return moment(time).calendar(null, {
sameDay: "[Today]",
lastDay: "[Yesterday]",
lastWeek: "D MMMM YYYY",
sameElse: "D MMMM YYYY"
});
};

View File

@ -0,0 +1,12 @@
"use strict";
// Return true if any section of "a" or "b" parts (defined by their start/end
// markers) intersect each other, false otherwise.
function anyIntersection(a, b) {
return a.start <= b.start && b.start < a.end ||
a.start < b.end && b.end <= a.end ||
b.start <= a.start && a.start < b.end ||
b.start < a.end && a.end <= b.end;
}
module.exports = anyIntersection;

View File

@ -0,0 +1,34 @@
"use strict";
// Create plain text entries corresponding to areas of the text that match no
// existing entries. Returns an empty array if all parts of the text have been
// parsed into recognizable entries already.
function fill(existingEntries, text) {
let position = 0;
// Fill inner parts of the text. For example, if text is `foobarbaz` and both
// `foo` and `baz` have matched into an entry, this will return a dummy entry
// corresponding to `bar`.
const result = existingEntries.reduce((acc, textSegment) => {
if (textSegment.start > position) {
acc.push({
start: position,
end: textSegment.start
});
}
position = textSegment.end;
return acc;
}, []);
// Complete the unmatched end of the text with a dummy entry
if (position < text.length) {
result.push({
start: position,
end: text.length
});
}
return result;
}
module.exports = fill;

View File

@ -0,0 +1,43 @@
"use strict";
// Escapes the RegExp special characters "^", "$", "", ".", "*", "+", "?", "(",
// ")", "[", "]", "{", "}", and "|" in string.
// See https://lodash.com/docs/#escapeRegExp
const escapeRegExp = require("lodash/escapeRegExp");
// Given an array of channel prefixes (such as "#" and "&") and an array of user
// modes (such as "@" and "+"), this function extracts channels and nicks from a
// text.
// It returns an array of objects for each channel found with their start index,
// end index and channel name.
function findChannels(text, channelPrefixes, userModes) {
// `userModePattern` is necessary to ignore user modes in /whois responses.
// For example, a voiced user in #thelounge will have a /whois response of:
// > foo is on the following channels: +#thelounge
// We need to explicitly ignore user modes to parse such channels correctly.
const userModePattern = userModes.map(escapeRegExp).join("");
const channelPrefixPattern = channelPrefixes.map(escapeRegExp).join("");
const channelPattern = `(?:^|\\s)[${userModePattern}]*([${channelPrefixPattern}][^ \u0007]+)`;
const channelRegExp = new RegExp(channelPattern, "g");
const result = [];
let match;
do {
// With global ("g") regexes, calling `exec` multiple times will find
// successive matches in the same string.
match = channelRegExp.exec(text);
if (match) {
result.push({
start: match.index + match[0].length - match[1].length,
end: match.index + match[0].length,
channel: match[1]
});
}
} while (match);
return result;
}
module.exports = findChannels;

View File

@ -0,0 +1,20 @@
"use strict";
const emojiRegExp = require("emoji-regex")();
function findEmoji(text) {
const result = [];
let match;
while ((match = emojiRegExp.exec(text))) {
result.push({
start: match.index,
end: match.index + match[0].length,
emoji: match[0]
});
}
return result;
}
module.exports = findEmoji;

View File

@ -0,0 +1,64 @@
"use strict";
const URI = require("urijs");
// Known schemes to detect in a text. If a text contains `foo...bar://foo.com`,
// the parsed scheme should be `foo...bar` but if it contains
// `foo...http://foo.com`, we assume the scheme to extract will be `http`.
const commonSchemes = [
"http", "https",
"ftp", "sftp",
"smb", "file",
"irc", "ircs",
"svn", "git",
"steam", "mumble", "ts3server",
"svn+ssh", "ssh",
];
function findLinks(text) {
const result = [];
// URI.withinString() identifies URIs within text, e.g. to translate them to
// <a>-Tags.
// See https://medialize.github.io/URI.js/docs.html#static-withinString
// In our case, we store each URI encountered in a result array.
URI.withinString(text, function(url, start, end) {
let parsedScheme;
try {
// Extract the scheme of the URL detected, if there is one
parsedScheme = URI(url).scheme().toLowerCase();
} catch (e) {
// URI may throw an exception for malformed urls,
// as to why withinString finds these in the first place is a mystery
return;
}
// Check if the scheme of the detected URL matches a common one above.
// In a URL like `foo..http://example.com`, the scheme would be `foo..http`,
// so we need to clean up the end of the scheme and filter out the rest.
const matchedScheme = commonSchemes.find((scheme) => parsedScheme.endsWith(scheme));
// A known scheme was found, extract the unknown part from the URL
if (matchedScheme) {
const prefix = parsedScheme.length - matchedScheme.length;
start += prefix;
url = url.slice(prefix);
}
// The URL matched but does not start with a scheme (`www.foo.com`), add it
if (!parsedScheme.length) {
url = "http://" + url;
}
result.push({
start: start,
end: end,
link: url
});
});
return result;
}
module.exports = findLinks;

View File

@ -0,0 +1,60 @@
"use strict";
const anyIntersection = require("./anyIntersection");
const fill = require("./fill");
let Object_assign = Object.assign;
if (typeof Object_assign !== "function") {
Object_assign = function(target) {
Array.prototype.slice.call(arguments, 1).forEach(function(obj) {
Object.keys(obj).forEach(function(key) {
target[key] = obj[key];
});
});
return target;
};
}
// Merge text part information within a styling fragment
function assign(textPart, fragment) {
const fragStart = fragment.start;
const start = Math.max(fragment.start, textPart.start);
const end = Math.min(fragment.end, textPart.end);
return Object_assign({}, fragment, {
start: start,
end: end,
text: fragment.text.slice(start - fragStart, end - fragStart)
});
}
// Merge the style fragments withing the text parts, taking into account
// boundaries and text sections that have not matched to links or channels.
// For example, given a string "foobar" where "foo" and "bar" have been
// identified as parts (channels, links, etc.) and "fo", "ob" and "ar" have 3
// different styles, the first resulting part will contain fragments "fo" and
// "o", and the second resulting part will contain "b" and "ar". "o" and "b"
// fragments will contain duplicate styling attributes.
function merge(textParts, styleFragments) {
// Re-build the overall text (without control codes) from the style fragments
const cleanText = styleFragments.reduce((acc, frag) => acc + frag.text, "");
// Every section of the original text that has not been captured in a "part"
// is filled with "text" parts, dummy objects with start/end but no extra
// metadata.
const allParts = textParts
.concat(fill(textParts, cleanText))
.sort((a, b) => a.start - b.start);
// Distribute the style fragments within the text parts
return allParts.map((textPart) => {
textPart.fragments = styleFragments
.filter((fragment) => anyIntersection(textPart, fragment))
.map((fragment) => assign(textPart, fragment));
return textPart;
});
}
module.exports = merge;

View File

@ -0,0 +1,190 @@
"use strict";
// Styling control codes
const BOLD = "\x02";
const COLOR = "\x03";
const HEX_COLOR = "\x04";
const RESET = "\x0f";
const REVERSE = "\x16";
const ITALIC = "\x1d";
const UNDERLINE = "\x1f";
// Color code matcher, with format `XX,YY` where both `XX` and `YY` are
// integers, `XX` is the text color and `YY` is an optional background color.
const colorRx = /^(\d{1,2})(?:,(\d{1,2}))?/;
// 6-char Hex color code matcher
const hexColorRx = /^([0-9a-f]{6})(?:,([0-9a-f]{6}))?/i;
// Represents all other control codes that to be ignored/filtered from the text
const controlCodesRx = /[\u0000-\u001F]/g;
// Converts a given text into an array of objects, each of them representing a
// similarly styled section of the text. Each object carries the `text`, style
// information (`bold`, `textColor`, `bgcolor`, `reverse`, `italic`,
// `underline`), and `start`/`end` cursors.
function parseStyle(text) {
const result = [];
let start = 0;
let position = 0;
// At any given time, these carry style information since last time a styling
// control code was met.
let colorCodes, bold, textColor, bgColor, hexColor, hexBgColor, reverse, italic, underline;
const resetStyle = () => {
bold = false;
textColor = undefined;
bgColor = undefined;
hexColor = undefined;
hexBgColor = undefined;
reverse = false;
italic = false;
underline = false;
};
resetStyle();
// When called, this "closes" the current fragment by adding an entry to the
// `result` array using the styling information set last time a control code
// was met.
const emitFragment = () => {
// Uses the text fragment starting from the last control code position up to
// the current position
const textPart = text.slice(start, position);
// Filters out all non-style related control codes present in this text
const processedText = textPart.replace(controlCodesRx, "");
if (processedText.length) {
// Current fragment starts where the previous one ends, or at 0 if none
const fragmentStart = result.length ? result[result.length - 1].end : 0;
result.push({
bold,
textColor,
bgColor,
hexColor,
hexBgColor,
reverse,
italic,
underline,
text: processedText,
start: fragmentStart,
end: fragmentStart + processedText.length
});
}
// Now that a fragment has been "closed", the next one will start after that
start = position + 1;
};
// This loop goes through each character of the given text one by one by
// bumping the `position` cursor. Every time a new special "styling" character
// is met, an object gets created (with `emitFragment()`)information on text
// encountered since the previous styling character.
while (position < text.length) {
switch (text[position]) {
case RESET:
emitFragment();
resetStyle();
break;
// Meeting a BOLD character means that the ongoing text is either going to
// be in bold or that the previous one was in bold and the following one
// must be reset.
// This same behavior applies to COLOR, REVERSE, ITALIC, and UNDERLINE.
case BOLD:
emitFragment();
bold = !bold;
break;
case COLOR:
emitFragment();
// Go one step further to find the corresponding color
colorCodes = text.slice(position + 1).match(colorRx);
if (colorCodes) {
textColor = Number(colorCodes[1]);
if (colorCodes[2]) {
bgColor = Number(colorCodes[2]);
}
// Color code length is > 1, so bump the current position cursor by as
// much (and reset the start cursor for the current text block as well)
position += colorCodes[0].length;
start = position + 1;
} else {
// If no color codes were found, toggles back to no colors (like BOLD).
textColor = undefined;
bgColor = undefined;
}
break;
case HEX_COLOR:
emitFragment();
colorCodes = text.slice(position + 1).match(hexColorRx);
if (colorCodes) {
hexColor = colorCodes[1].toUpperCase();
if (colorCodes[2]) {
hexBgColor = colorCodes[2].toUpperCase();
}
// Color code length is > 1, so bump the current position cursor by as
// much (and reset the start cursor for the current text block as well)
position += colorCodes[0].length;
start = position + 1;
} else {
// If no color codes were found, toggles back to no colors (like BOLD).
hexColor = undefined;
hexBgColor = undefined;
}
break;
case REVERSE:
emitFragment();
reverse = !reverse;
break;
case ITALIC:
emitFragment();
italic = !italic;
break;
case UNDERLINE:
emitFragment();
underline = !underline;
break;
}
// Evaluate the next character at the next iteration
position += 1;
}
// The entire text has been parsed, so we finalize the current text fragment.
emitFragment();
return result;
}
const properties = ["bold", "textColor", "bgColor", "hexColor", "hexBgColor", "italic", "underline", "reverse"];
function prepare(text) {
return parseStyle(text)
// This optimizes fragments by combining them together when all their values
// for the properties defined above are equal.
.reduce((prev, curr) => {
if (prev.length) {
const lastEntry = prev[prev.length - 1];
if (properties.every((key) => curr[key] === lastEntry[key])) {
lastEntry.text += curr.text;
lastEntry.end += curr.text.length;
return prev;
}
}
return prev.concat([curr]);
}, []);
}
module.exports = prepare;

View File

@ -1,3 +1,7 @@
"use strict";
const moment = require("moment");
module.exports = function(time) { module.exports = function(time) {
return new Date(time).toLocaleDateString(); return moment(time).format("D MMMM YYYY");
}; };

View File

@ -1,3 +1,7 @@
"use strict";
const moment = require("moment");
module.exports = function(time) { module.exports = function(time) {
return new Date(time).toLocaleString(); return moment(time).format("D MMMM YYYY, HH:mm:ss");
}; };

View File

@ -1,3 +1,5 @@
"use strict";
module.exports = function(mode) { module.exports = function(mode) {
var modes = { var modes = {
"~": "owner", "~": "owner",

View File

@ -1,125 +1,90 @@
import Handlebars from "handlebars/runtime"; "use strict";
import URI from "urijs";
module.exports = function(text) { const Handlebars = require("handlebars/runtime");
text = Handlebars.Utils.escapeExpression(text); const parseStyle = require("./ircmessageparser/parseStyle");
text = colors(text); const findChannels = require("./ircmessageparser/findChannels");
text = channels(text); const findLinks = require("./ircmessageparser/findLinks");
text = uri(text); const findEmoji = require("./ircmessageparser/findEmoji");
return text; const merge = require("./ircmessageparser/merge");
// Create an HTML `span` with styling information for a given fragment
function createFragment(fragment) {
const classes = [];
if (fragment.bold) {
classes.push("irc-bold");
}
if (fragment.textColor !== undefined) {
classes.push("irc-fg" + fragment.textColor);
}
if (fragment.bgColor !== undefined) {
classes.push("irc-bg" + fragment.bgColor);
}
if (fragment.italic) {
classes.push("irc-italic");
}
if (fragment.underline) {
classes.push("irc-underline");
}
let attributes = classes.length ? ` class="${classes.join(" ")}"` : "";
const escapedText = Handlebars.Utils.escapeExpression(fragment.text);
if (fragment.hexColor) {
attributes += ` style="color:#${fragment.hexColor}`;
if (fragment.hexBgColor) {
attributes += `;background-color:#${fragment.hexBgColor}`;
}
attributes += "\"";
}
if (attributes.length) {
return `<span${attributes}>${escapedText}</span>`;
}
return escapedText;
}
// Transform an IRC message potentially filled with styling control codes, URLs
// and channels into a string of HTML elements to display on the client.
module.exports = function parse(text) {
// Extract the styling information and get the plain text version from it
const styleFragments = parseStyle(text);
const cleanText = styleFragments.map((fragment) => fragment.text).join("");
// On the plain text, find channels and URLs, returned as "parts". Parts are
// arrays of objects containing start and end markers, as well as metadata
// depending on what was found (channel or link).
const channelPrefixes = ["#", "&"]; // TODO Channel prefixes should be RPL_ISUPPORT.CHANTYPES
const userModes = ["!", "@", "%", "+"]; // TODO User modes should be RPL_ISUPPORT.PREFIX
const channelParts = findChannels(cleanText, channelPrefixes, userModes);
const linkParts = findLinks(cleanText);
const emojiParts = findEmoji(cleanText);
// Sort all parts identified based on their position in the original text
const parts = channelParts
.concat(linkParts)
.concat(emojiParts)
.sort((a, b) => a.start - b.start);
// Merge the styling information with the channels / URLs / text objects and
// generate HTML strings with the resulting fragments
return merge(parts, styleFragments).map((textPart) => {
// Create HTML strings with styling information
const fragments = textPart.fragments.map(createFragment).join("");
// Wrap these potentially styled fragments with links and channel buttons
if (textPart.link) {
const escapedLink = Handlebars.Utils.escapeExpression(textPart.link);
return `<a href="${escapedLink}" target="_blank" rel="noopener">${fragments}</a>`;
} else if (textPart.channel) {
const escapedChannel = Handlebars.Utils.escapeExpression(textPart.channel);
return `<span class="inline-channel" role="button" tabindex="0" data-chan="${escapedChannel}">${fragments}</span>`;
} else if (textPart.emoji) {
return `<span class="emoji">${fragments}</span>`;
}
return fragments;
}).join("");
}; };
function uri(text) {
return URI.withinString(text, function(url) {
if (url.indexOf("javascript:") === 0) {
return url;
}
var split = url.split("<");
url = "<a href='" + split[0].replace(/^www/, "http://www") + "' target='_blank' rel='noopener'>" + split[0] + "</a>";
if (split.length > 1) {
url += "<" + split.slice(1).join("<");
}
return url;
});
}
/**
* Channels names are strings of length up to fifty (50) characters.
* The only restriction on a channel name is that it SHALL NOT contain
* any spaces (' '), a control G (^G or ASCII 7), a comma (',').
* Channel prefix '&' is handled as '&amp;' because this parser is executed
* after entities in the message have been escaped. This prevents a couple of bugs.
*/
function channels(text) {
return text.replace(
/(^|\s|\x07|,)((?:#|&amp;)[^\x07\s,]{1,49})/g,
'$1<span class="inline-channel" role="button" tabindex="0" data-chan="$2">$2</span>'
);
}
/**
* MIRC compliant colour and style parser
* Unfortuanately this is a non trivial operation
* See this branch for source and tests
* https://github.com/megawac/irc-style-parser/tree/shout
*/
var styleCheck_Re = /[\x00-\x1F]/,
back_re = /^([0-9]{1,2})(,([0-9]{1,2}))?/,
colourKey = "\x03",
// breaks all open styles ^O (\x0F)
styleBreak = "\x0F";
function styleTemplate(settings) {
return "<span class='" + settings.style + "'>" + settings.text + "</span>";
}
var styles = [
["normal", "\x00", ""], ["underline", "\x1F"],
["bold", "\x02"], ["italic", "\x1D"]
].map(function(style) {
var escaped = encodeURI(style[1]).replace("%", "\\x");
return {
name: style[0],
style: style[2] ? style[2] : "irc-" + style[0],
key: style[1],
keyregex: new RegExp(escaped + "(.*?)(" + escaped + "|$)")
};
});
function colors(line) {
// http://www.mirc.com/colors.html
// http://www.aviran.org/stripremove-irc-client-control-characters/
// https://github.com/perl6/mu/blob/master/examples/rules/Grammar-IRC.pm
// regexs are cruel to parse this thing
// already done?
if (!styleCheck_Re.test(line)) {
return line;
}
// split up by the irc style break character ^O
if (line.indexOf(styleBreak) >= 0) {
return line.split(styleBreak).map(colors).join("");
}
var result = line;
var parseArr = result.split(colourKey);
var text, match, colour, background = "";
for (var i = 0; i < parseArr.length; i++) {
text = parseArr[i];
match = text.match(back_re);
if (!match) {
// ^C (no colour) ending. Escape current colour and carry on
background = "";
continue;
}
colour = "irc-fg" + +match[1];
// set the background colour
if (match[3]) {
background = " irc-bg" + +match[3];
}
// update the parsed text result
result = result.replace(colourKey + text, styleTemplate({
style: colour + background,
text: text.slice(match[0].length)
}));
}
// Matching styles (italics/bold/underline)
// if only colours were this easy...
styles.forEach(function(style) {
if (result.indexOf(style.key) < 0) {
return;
}
result = result.replace(style.keyregex, function(matchedTrash, matchedText) {
return styleTemplate({
style: style.style,
text: matchedText
});
});
});
return result;
}

View File

@ -1,3 +1,5 @@
"use strict";
module.exports = function(count) { module.exports = function(count) {
if (count < 1000) { if (count < 1000) {
return count; return count;

View File

@ -0,0 +1,5 @@
"use strict";
module.exports = function(orig) {
return orig.toLowerCase().replace(/[^a-z0-9]/, "-");
};

View File

@ -1,3 +1,5 @@
"use strict";
module.exports = function(context) { module.exports = function(context) {
return window.JSON.stringify(context); return window.JSON.stringify(context);
}; };

View File

@ -1,15 +1,10 @@
"use strict";
const moment = require("moment");
const constants = require("../../constants");
module.exports = function(time) { module.exports = function(time) {
time = new Date(time); const options = require("../../options");
var h = time.getHours(); const format = options.showSeconds ? constants.timeFormats.msgWithSeconds : constants.timeFormats.msgDefault;
var m = time.getMinutes(); return moment(time).format(format);
if (h < 10) {
h = "0" + h;
}
if (m < 10) {
m = "0" + m;
}
return h + ":" + m;
}; };

View File

@ -1,3 +0,0 @@
module.exports = function(count) {
return count + " " + (count === 1 ? "user" : "users");
};

View File

@ -34,7 +34,7 @@ import jQuery from "jquery";
var key = e.which; var key = e.which;
switch (key) { switch (key) {
case 13: // Enter case 13: // Enter
if (e.shiftKey) { if (e.shiftKey || self.data("autocompleting")) {
return; // multiline input return; // multiline input
} }
@ -56,7 +56,7 @@ import jQuery from "jquery";
case 38: // Up case 38: // Up
case 40: // Down case 40: // Down
// NOTICE: This is specific to The Lounge. // NOTICE: This is specific to The Lounge.
if (e.ctrlKey || e.metaKey) { if (e.ctrlKey || e.metaKey || self.data("autocompleting")) {
break; break;
} }

View File

@ -37,7 +37,7 @@ import jQuery from "jquery";
lastStick = Date.now(); lastStick = Date.now();
this.scrollTop = this.scrollHeight; this.scrollTop = this.scrollHeight;
}) })
.on("msg.sticky", keepToBottom) .on("keepToBottom.sticky", keepToBottom)
.scrollBottom(); .scrollBottom();
return self; return self;

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,9 @@
"use strict";
/** /**
* Simple slideout menu implementation. * Simple slideout menu implementation.
*/ */
export default function slideoutMenu(viewport, menu) { module.exports = function slideoutMenu(viewport, menu) {
var touchStartPos = null; var touchStartPos = null;
var touchCurPos = null; var touchCurPos = null;
var touchStartTime = 0; var touchStartTime = 0;
@ -21,7 +23,7 @@ export default function slideoutMenu(viewport, menu) {
function onTouchStart(e) { function onTouchStart(e) {
if (e.touches.length !== 1) { if (e.touches.length !== 1) {
onTouchEnd(); onTouchEnd();
return false; return;
} }
var touch = e.touches.item(0); var touch = e.touches.item(0);
@ -35,7 +37,7 @@ export default function slideoutMenu(viewport, menu) {
touchStartTime = Date.now(); touchStartTime = Date.now();
viewport.addEventListener("touchmove", onTouchMove); viewport.addEventListener("touchmove", onTouchMove);
viewport.addEventListener("touchend", onTouchEnd); viewport.addEventListener("touchend", onTouchEnd, {passive: true});
} }
} }
@ -89,7 +91,7 @@ export default function slideoutMenu(viewport, menu) {
menuIsMoving = false; menuIsMoving = false;
} }
viewport.addEventListener("touchstart", onTouchStart); viewport.addEventListener("touchstart", onTouchStart, {passive: true});
return { return {
disable: disableSlideout, disable: disableSlideout,
@ -98,4 +100,4 @@ export default function slideoutMenu(viewport, menu) {
return menuIsOpen; return menuIsOpen;
} }
}; };
} };

View File

@ -15,3 +15,7 @@ setTimeout(function() {
element.style.display = "block"; element.style.display = "block";
} }
}, 5000); }, 5000);
document.getElementById("loading-slow-reload").addEventListener("click", function() {
location.reload();
});

19
client/js/localStorage.js Normal file
View File

@ -0,0 +1,19 @@
"use strict";
module.exports = {
set: function(key, value) {
try {
window.localStorage.setItem(key, value);
} catch (e) {
// Do nothing. If we end up here, web storage quota exceeded, or user is
// in Safari's private browsing where localStorage's setItem is not
// available. See http://stackoverflow.com/q/14555347/1935861.
}
},
get: function(key) {
return window.localStorage.getItem(key);
},
remove: function(key, value) {
window.localStorage.removeItem(key, value);
}
};

File diff suppressed because it is too large Load Diff

162
client/js/options.js Normal file
View File

@ -0,0 +1,162 @@
"use strict";
const $ = require("jquery");
const escapeRegExp = require("lodash/escapeRegExp");
const settings = $("#settings");
const userStyles = $("#user-specified-css");
const storage = require("./localStorage");
const tz = require("./libs/handlebars/tz");
const windows = $("#windows");
const chat = $("#chat");
// Default options
const options = {
autocomplete: true,
coloredNicks: true,
desktopNotifications: false,
highlights: [],
links: true,
motd: true,
notification: true,
notifyAllMessages: false,
showSeconds: false,
statusMessages: "condensed",
theme: $("#theme").attr("href").replace(/^themes\/(.*).css$/, "$1"), // Extracts default theme name, set on the server configuration
thumbnails: true,
userStyles: userStyles.text(),
};
let userOptions = JSON.parse(storage.get("settings")) || {};
for (const key in options) {
if (userOptions[key] !== undefined) {
options[key] = userOptions[key];
}
}
userOptions = null;
module.exports = options;
module.exports.shouldOpenMessagePreview = function(type) {
return (options.links && type === "link") || (options.thumbnails && type === "image");
};
for (var i in options) {
if (i === "userStyles") {
if (!/[?&]nocss/.test(window.location.search)) {
$(document.head).find("#user-specified-css").html(options[i]);
}
settings.find("#user-specified-css-input").val(options[i]);
} else if (i === "highlights") {
settings.find("input[name=" + i + "]").val(options[i]);
} else if (i === "statusMessages") {
settings.find(`input[name=${i}][value=${options[i]}]`)
.prop("checked", true);
} else if (i === "theme") {
$("#theme").attr("href", "themes/" + options[i] + ".css");
settings.find("select[name=" + i + "]").val(options[i]);
} else if (options[i]) {
settings.find("input[name=" + i + "]").prop("checked", true);
}
}
settings.on("change", "input, select, textarea", function() {
const self = $(this);
const type = self.attr("type");
const name = self.attr("name");
if (type === "password") {
return;
} else if (type === "radio") {
if (self.prop("checked")) {
options[name] = self.val();
}
} else if (type === "checkbox") {
options[name] = self.prop("checked");
} else {
options[name] = self.val();
}
storage.set("settings", JSON.stringify(options));
if (name === "motd") {
chat.toggleClass("hide-" + name, !self.prop("checked"));
} else if (name === "statusMessages") {
chat.toggleClass("hide-status-messages", options[name] === "hidden");
} else if (name === "coloredNicks") {
chat.toggleClass("colored-nicks", self.prop("checked"));
} else if (name === "theme") {
$("#theme").attr("href", "themes/" + options[name] + ".css");
} else if (name === "userStyles") {
userStyles.html(options[name]);
} else if (name === "highlights") {
var highlightString = options[name];
options.highlights = highlightString.split(",").map(function(h) {
return h.trim();
}).filter(function(h) {
// Ensure we don't have empty string in the list of highlights
// otherwise, users get notifications for everything
return h !== "";
});
// Construct regex with wordboundary for every highlight item
const highlightsTokens = options.highlights.map(function(h) {
return escapeRegExp(h);
});
if (highlightsTokens && highlightsTokens.length) {
module.exports.highlightsRE = new RegExp("\\b(?:" + highlightsTokens.join("|") + ")\\b", "i");
} else {
module.exports.highlightsRE = null;
}
} else if (name === "showSeconds") {
chat.find(".msg > .time").each(function() {
$(this).text(tz($(this).parent().data("time")));
});
} else if (name === "autocomplete") {
if (self.prop("checked")) {
$("#input").trigger("autocomplete:on");
} else {
$("#input").textcomplete("destroy");
}
}
}).find("input")
.trigger("change");
$("#desktopNotifications").on("change", function() {
if ($(this).prop("checked") && Notification.permission !== "granted") {
Notification.requestPermission(updateDesktopNotificationStatus);
}
});
// Updates the checkbox and warning in settings when the Settings page is
// opened or when the checkbox state is changed.
// When notifications are not supported, this is never called (because
// checkbox state can not be changed).
var updateDesktopNotificationStatus = function() {
if (Notification.permission === "denied") {
desktopNotificationsCheckbox.attr("disabled", true);
desktopNotificationsCheckbox.attr("checked", false);
warningBlocked.show();
} else {
if (Notification.permission === "default" && desktopNotificationsCheckbox.prop("checked")) {
desktopNotificationsCheckbox.attr("checked", false);
}
desktopNotificationsCheckbox.attr("disabled", false);
warningBlocked.hide();
}
};
// If browser does not support notifications, override existing settings and
// display proper message in settings.
var desktopNotificationsCheckbox = $("#desktopNotifications");
var warningUnsupported = $("#warnUnsupportedDesktopNotifications");
var warningBlocked = $("#warnBlockedDesktopNotifications");
warningBlocked.hide();
if (("Notification" in window)) {
warningUnsupported.hide();
windows.on("show", "#settings", updateDesktopNotificationStatus);
} else {
options.desktopNotifications = false;
desktopNotificationsCheckbox.attr("disabled", true);
desktopNotificationsCheckbox.attr("checked", false);
}

222
client/js/render.js Normal file
View File

@ -0,0 +1,222 @@
"use strict";
const $ = require("jquery");
const templates = require("../views");
const options = require("./options");
const renderPreview = require("./renderPreview");
const utils = require("./utils");
const sorting = require("./sorting");
const constants = require("./constants");
const condensed = require("./condensed");
const chat = $("#chat");
const sidebar = $("#sidebar");
module.exports = {
appendMessage,
buildChannelMessages,
buildChatMessage,
renderChannel,
renderChannelMessages,
renderChannelUsers,
renderNetworks,
};
function buildChannelMessages(data) {
return data.messages.reduce(function(docFragment, message) {
appendMessage(docFragment, data.id, data.type, message.type, buildChatMessage({
chan: data.id,
msg: message
}));
return docFragment;
}, $(document.createDocumentFragment()));
}
function appendMessage(container, chan, chanType, messageType, msg) {
// TODO: To fix #1432, statusMessage option should entirely be implemented in CSS
if (constants.condensedTypes.indexOf(messageType) === -1 || chanType !== "channel" || options.statusMessages !== "condensed") {
container.append(msg);
return;
}
const lastChild = container.children("div.msg").last();
if (lastChild && $(lastChild).hasClass("condensed")) {
lastChild.append(msg);
condensed.updateText(lastChild, [messageType]);
} else if (lastChild && $(lastChild).is(constants.condensedTypesQuery)) {
const newCondensed = buildChatMessage({
chan: chan,
msg: {
type: "condensed",
time: msg.attr("data-time"),
previews: []
}
});
condensed.updateText(newCondensed, [messageType, lastChild.attr("data-type")]);
container.append(newCondensed);
newCondensed.append(lastChild);
newCondensed.append(msg);
} else {
container.append(msg);
}
}
function buildChatMessage(data) {
const type = data.msg.type;
let target = "#chan-" + data.chan;
if (type === "error") {
target = "#chan-" + chat.find(".active").data("id");
}
const chan = chat.find(target);
let template = "msg";
// See if any of the custom highlight regexes match
if (!data.msg.highlight && !data.msg.self
&& options.highlightsRE
&& (type === "message" || type === "notice")
&& options.highlightsRE.exec(data.msg.text)) {
data.msg.highlight = true;
}
if (constants.actionTypes.indexOf(type) !== -1) {
data.msg.template = "actions/" + type;
template = "msg_action";
} else if (type === "unhandled") {
template = "msg_unhandled";
} else if (type === "condensed") {
template = "msg_condensed";
}
const msg = $(templates[template](data.msg));
const content = msg.find(".content");
if (template === "msg_action") {
content.html(templates.actions[type](data.msg));
}
data.msg.previews.forEach((preview) => {
renderPreview(preview, msg);
});
if ((type === "message" || type === "action" || type === "notice") && chan.hasClass("channel")) {
const nicks = chan.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);
}
}
}
return msg;
}
function renderChannel(data) {
renderChannelMessages(data);
if (data.type === "channel") {
renderChannelUsers(data);
}
}
function renderChannelMessages(data) {
const documentFragment = buildChannelMessages(data);
const channel = chat.find("#chan-" + data.id + " .messages").append(documentFragment);
if (data.firstUnread > 0) {
const first = channel.find("#msg-" + data.firstUnread);
// TODO: If the message is far off in the history, we still need to append the marker into DOM
if (!first.length) {
channel.prepend(templates.unread_marker());
} else if (first.parent().hasClass("condensed")) {
first.parent().before(templates.unread_marker());
} else {
first.before(templates.unread_marker());
}
} else {
channel.append(templates.unread_marker());
}
if (data.type !== "lobby") {
let lastDate;
$(chat.find("#chan-" + data.id + " .messages .msg[data-time]")).each(function() {
const msg = $(this);
const msgDate = new Date(msg.attr("data-time"));
// Top-most message in a channel
if (!lastDate) {
lastDate = msgDate;
msg.before(templates.date_marker({msgDate: msgDate}));
}
if (lastDate.toDateString() !== msgDate.toDateString()) {
var parent = msg.parent();
if (parent.hasClass("condensed")) {
msg.insertAfter(parent);
}
msg.before(templates.date_marker({msgDate: msgDate}));
}
lastDate = msgDate;
});
}
}
function renderChannelUsers(data) {
const users = chat.find("#chan-" + data.id).find(".users");
const nicks = data.users
.concat() // Make a copy of the user list, sort is applied in-place
.sort((a, b) => b.lastMessage - a.lastMessage)
.map((a) => a.nick);
const search = users
.find(".search")
.attr("placeholder", nicks.length + " " + (nicks.length === 1 ? "user" : "users"));
users
.data("nicks", nicks)
.find(".names-original")
.html(templates.user(data));
// Refresh user search
if (search.val().length) {
search.trigger("input");
}
}
function renderNetworks(data) {
sidebar.find(".empty").hide();
sidebar.find(".networks").append(
templates.network({
networks: data.networks
})
);
const channels = $.map(data.networks, function(n) {
return n.channels;
});
chat.append(
templates.chat({
channels: channels
})
);
channels.forEach((channel) => {
renderChannel(channel);
if (channel.type === "channel") {
chat.find("#chan-" + channel.id).data("needsNamesRefresh", true);
}
});
utils.confirmExit();
sorting();
if (sidebar.find(".highlight").length) {
utils.toggleNotificationMarkers(true);
}
}

185
client/js/renderPreview.js Normal file
View File

@ -0,0 +1,185 @@
"use strict";
const $ = require("jquery");
const options = require("./options");
const socket = require("./socket");
const templates = require("../views");
const input = $("#input");
module.exports = renderPreview;
function renderPreview(preview, msg) {
if (preview.type === "loading") {
return;
}
const escapedLink = preview.link.replace(/["\\]/g, "\\$&");
const previewContainer = msg.find(`.preview[data-url="${escapedLink}"]`);
// This is to fix a very rare case of rendering a preview twice
// This happens when a very large amount of messages is being sent to the client
// and they get queued, so the `preview` object on the server has time to load before
// it actually gets sent to the server, which makes the loaded preview sent twice,
// once in `msg` and another in `msg:preview`
if (!previewContainer.is(":empty")) {
return;
}
preview.shown = preview.shown && options.shouldOpenMessagePreview(preview.type);
const container = msg.closest(".chat");
let bottom = false;
if (container.length) {
bottom = container.isScrollBottom();
}
msg.find(`.text a[href="${escapedLink}"]`)
.first()
.after(templates.msg_preview_toggle({preview: preview}).trim());
previewContainer
.append(templates.msg_preview({preview: preview}));
if (preview.shown && bottom) {
handleImageInPreview(msg.find(".toggle-content"), container);
}
container.trigger("keepToBottom");
}
$("#chat").on("click", ".text .toggle-button", function() {
const self = $(this);
const container = self.closest(".chat");
const content = self.closest(".content")
.find(`.preview[data-url="${self.data("url")}"] .toggle-content`);
const bottom = container.isScrollBottom();
if (bottom && !content.hasClass("show")) {
handleImageInPreview(content, container);
}
self.toggleClass("opened");
content.toggleClass("show");
// Tell the server we're toggling so it remembers at page reload
// TODO Avoid sending many single events when using `/collapse` or `/expand`
// See https://github.com/thelounge/lounge/issues/1377
socket.emit("msg:preview:toggle", {
target: parseInt(self.closest(".chan").data("id"), 10),
msgId: parseInt(self.closest(".msg").attr("id").replace("msg-", ""), 10),
link: self.data("url"),
shown: content.hasClass("show"),
});
// If scrollbar was at the bottom before toggling the preview, keep it at the bottom
if (bottom) {
container.scrollBottom();
}
});
function handleImageInPreview(content, container) {
const img = content.find("img");
// Trigger scroll logic after the image loads
if (img.length && !img.width()) {
img.on("load", function() {
container.trigger("keepToBottom");
});
}
}
/* Image viewer */
const imageViewer = $("#image-viewer");
$("#chat").on("click", ".toggle-thumbnail", function() {
const link = $(this);
openImageViewer(link);
// 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.
// By binding this event on #chat we prevent input gaining focus after clicking.
return false;
});
imageViewer.on("click", function() {
closeImageViewer();
});
$(document).keydown(function(e) {
switch (e.keyCode ? e.keyCode : e.which) {
case 27: // Escape
closeImageViewer();
break;
case 37: // Left arrow
if (imageViewer.hasClass("opened")) {
imageViewer.find(".previous-image-btn").click();
}
break;
case 39: // Right arrow
if (imageViewer.hasClass("opened")) {
imageViewer.find(".next-image-btn").click();
}
break;
}
});
function openImageViewer(link) {
$(".previous-image").removeClass("previous-image");
$(".next-image").removeClass("next-image");
// The next two blocks figure out what are the previous/next images. We first
// look within the same message, as there can be multiple thumbnails per
// message, and if not, we look at previous/next messages and take the
// last/first thumbnail available.
// Only expanded thumbnails are being cycled through.
// Previous image
let previousImage = link.closest(".preview").prev(".preview")
.find(".toggle-content.show .toggle-thumbnail").last();
if (!previousImage.length) {
previousImage = link.closest(".msg").prevAll()
.find(".toggle-content.show .toggle-thumbnail").last();
}
previousImage.addClass("previous-image");
// Next image
let nextImage = link.closest(".preview").next(".preview")
.find(".toggle-content.show .toggle-thumbnail").first();
if (!nextImage.length) {
nextImage = link.closest(".msg").nextAll()
.find(".toggle-content.show .toggle-thumbnail").first();
}
nextImage.addClass("next-image");
imageViewer.html(templates.image_viewer({
image: link.find("img").attr("src"),
link: link.attr("href"),
type: link.parent().hasClass("toggle-type-image") ? "image" : "link",
hasPreviousImage: previousImage.length > 0,
hasNextImage: nextImage.length > 0,
}));
imageViewer.addClass("opened");
}
imageViewer.on("click", ".previous-image-btn", function() {
$(".previous-image").click();
return false;
});
imageViewer.on("click", ".next-image-btn", function() {
$(".next-image").click();
return false;
});
function closeImageViewer() {
imageViewer
.removeClass("opened")
.one("transitionend", function() {
imageViewer.empty();
});
input.focus();
}

View File

@ -0,0 +1,46 @@
"use strict";
const $ = require("jquery");
const socket = require("../socket");
const storage = require("../localStorage");
socket.on("auth", function(data) {
const login = $("#sign-in");
let token;
const user = storage.get("user");
login.find(".btn").prop("disabled", false);
if (!data.success) {
storage.remove("token");
const error = login.find(".error");
error.show().closest("form").one("submit", function() {
error.hide();
});
} else if (user) {
token = storage.get("token");
if (token) {
$("#loading-page-message").text("Authorizing…");
socket.emit("auth", {user: user, token: token});
}
}
if (user) {
login.find("input[name='user']").val(user);
}
if (token) {
return;
}
$("#footer").find(".sign-in")
.trigger("click", {
pushState: false,
})
.end()
.find(".networks")
.html("")
.next()
.show();
});

View File

@ -0,0 +1,30 @@
"use strict";
const $ = require("jquery");
const socket = require("../socket");
socket.on("change-password", function(data) {
const passwordForm = $("#change-password");
if (data.error || data.success) {
const message = data.success ? data.success : data.error;
const feedback = passwordForm.find(".feedback");
if (data.success) {
feedback.addClass("success").removeClass("error");
} else {
feedback.addClass("error").removeClass("success");
}
feedback.text(message).show();
feedback.closest("form").one("submit", function() {
feedback.hide();
});
}
passwordForm
.find("input")
.val("")
.end()
.find(".btn")
.prop("disabled", false);
});

View File

@ -0,0 +1,19 @@
"use strict";
require("./auth");
require("./change_password");
require("./init");
require("./join");
require("./more");
require("./msg");
require("./msg_preview");
require("./names");
require("./network");
require("./nick");
require("./open");
require("./part");
require("./quit");
require("./sync_sort");
require("./topic");
require("./users");
require("./sign_out");

View File

@ -0,0 +1,45 @@
"use strict";
const $ = require("jquery");
const socket = require("../socket");
const render = require("../render");
const webpush = require("../webpush");
const sidebar = $("#sidebar");
const storage = require("../localStorage");
socket.on("init", function(data) {
$("#loading-page-message").text("Rendering…");
if (data.networks.length === 0) {
$("#footer").find(".connect").trigger("click", {
pushState: false,
});
} else {
render.renderNetworks(data);
}
if (data.token) {
storage.set("token", data.token);
}
webpush.configurePushNotifications(data.pushSubscription, data.applicationServerKey);
$("body").removeClass("signed-out");
$("#loading").remove();
$("#sign-in").remove();
const id = data.active;
const target = sidebar.find("[data-id='" + id + "']").trigger("click", {
replaceHistory: true
});
if (target.length === 0) {
const first = sidebar.find(".chan")
.eq(0)
.trigger("click");
if (first.length === 0) {
$("#footer").find(".connect").trigger("click", {
pushState: false,
});
}
}
});

View File

@ -0,0 +1,36 @@
"use strict";
const $ = require("jquery");
const socket = require("../socket");
const render = require("../render");
const chat = $("#chat");
const templates = require("../../views");
const sidebar = $("#sidebar");
socket.on("join", function(data) {
const id = data.network;
const network = sidebar.find("#network-" + id);
network.append(
templates.chan({
channels: [data.chan]
})
);
chat.append(
templates.chat({
channels: [data.chan]
})
);
render.renderChannel(data.chan);
// Queries do not automatically focus, unless the user did a whois
if (data.chan.type === "query" && !data.shouldOpen) {
return;
}
sidebar.find(".chan")
.sort(function(a, b) {
return $(a).data("id") - $(b).data("id");
})
.last()
.click();
});

View File

@ -0,0 +1,76 @@
"use strict";
const $ = require("jquery");
const socket = require("../socket");
const render = require("../render");
const chat = $("#chat");
const templates = require("../../views");
socket.on("more", function(data) {
const chan = chat
.find("#chan-" + data.chan)
.find(".messages");
// get the scrollable wrapper around messages
const scrollable = chan.closest(".chat");
const heightOld = chan.height();
// If there are no more messages to show, just hide the button and do nothing else
if (!data.messages.length) {
scrollable.find(".show-more").removeClass("show");
return;
}
// Remove the date-change marker we put at the top, because it may
// not actually be a date change now
const children = $(chan).children();
if (children.eq(0).hasClass("date-marker-container")) { // Check top most child
children.eq(0).remove();
} else if (children.eq(1).hasClass("date-marker-container")) {
// The unread-marker could be at index 0, which will cause the date-marker to become "stuck"
children.eq(1).remove();
} else if (children.eq(0).hasClass("condensed") && children.eq(0).children(".date-marker-container").eq(0).hasClass("date-marker-container")) {
children.eq(0).children(".date-marker-container").eq(0).remove();
}
// Add the older messages
const documentFragment = render.buildChannelMessages(data);
chan.prepend(documentFragment).end();
// restore scroll position
const position = chan.height() - heightOld;
scrollable.scrollTop(position);
if (data.messages.length !== 100) {
scrollable.find(".show-more").removeClass("show");
}
// Date change detect
// Have to use data instead of the documentFragment because it's being weird
let lastDate;
$(data.messages).each(function() {
const msgData = this;
const msgDate = new Date(msgData.time);
const msg = $(chat.find("#chan-" + data.chan + " .messages #msg-" + msgData.id));
// Top-most message in a channel
if (!lastDate) {
lastDate = msgDate;
msg.before(templates.date_marker({msgDate: msgDate}));
}
if (lastDate.toDateString() !== msgDate.toDateString()) {
var parent = msg.parent();
if (parent.hasClass("condensed")) {
msg.insertAfter(parent);
}
msg.before(templates.date_marker({msgDate: msgDate}));
}
lastDate = msgDate;
});
scrollable.find(".show-more-button")
.text("Show older messages")
.prop("disabled", false);
});

View File

@ -0,0 +1,87 @@
"use strict";
const $ = require("jquery");
const socket = require("../socket");
const render = require("../render");
const chat = $("#chat");
const templates = require("../../views");
socket.on("msg", function(data) {
if (window.requestIdleCallback) {
// During an idle period the user agent will run idle callbacks in FIFO order
// until either the idle period ends or there are no more idle callbacks eligible to be run.
// We set a maximum timeout of 2 seconds so that messages don't take too long to appear.
window.requestIdleCallback(() => processReceivedMessage(data), {timeout: 2000});
} else {
processReceivedMessage(data);
}
});
function processReceivedMessage(data) {
const msg = render.buildChatMessage(data);
const targetId = data.chan;
const target = "#chan-" + targetId;
const channel = chat.find(target);
const container = channel.find(".messages");
const activeChannelId = chat.find(".chan.active").data("id");
if (data.msg.type === "channel_list" || data.msg.type === "ban_list") {
$(container).empty();
}
// Check if date changed
let prevMsg = $(container.find(".msg")).last();
const prevMsgTime = new Date(prevMsg.attr("data-time"));
const msgTime = new Date(msg.attr("data-time"));
// It's the first message in a channel/query
if (prevMsg.length === 0) {
container.append(templates.date_marker({msgDate: msgTime}));
}
if (prevMsgTime.toDateString() !== msgTime.toDateString()) {
var parent = prevMsg.parent();
if (parent.hasClass("condensed")) {
prevMsg = parent;
}
prevMsg.after(templates.date_marker({msgDate: msgTime}));
}
// Add message to the container
render.appendMessage(
container,
data.chan,
$(target).attr("data-type"),
data.msg.type,
msg
);
container.trigger("msg", [
target,
data
]).trigger("keepToBottom");
var lastVisible = container.find("div:visible").last();
if (data.msg.self
|| lastVisible.hasClass("unread-marker")
|| (lastVisible.hasClass("date-marker")
&& lastVisible.prev().hasClass("unread-marker"))) {
container
.find(".unread-marker")
.appendTo(container);
}
// Message arrived in a non active channel, trim it to 100 messages
if (activeChannelId !== targetId && container.find(".msg").slice(0, -100).remove().length) {
channel.find(".show-more").addClass("show");
// Remove date-separators that would otherwise
// be "stuck" at the top of the channel
channel.find(".date-marker-container").each(function() {
if ($(this).next().hasClass("date-marker-container")) {
$(this).remove();
}
});
}
}

View File

@ -0,0 +1,11 @@
"use strict";
const $ = require("jquery");
const renderPreview = require("../renderPreview");
const socket = require("../socket");
socket.on("msg:preview", function(data) {
const msg = $("#msg-" + data.id);
renderPreview(data.preview, msg);
});

View File

@ -0,0 +1,6 @@
"use strict";
const socket = require("../socket");
const render = require("../render");
socket.on("names", render.renderChannelUsers);

View File

@ -0,0 +1,24 @@
"use strict";
const $ = require("jquery");
const socket = require("../socket");
const render = require("../render");
const sidebar = $("#sidebar");
socket.on("network", function(data) {
render.renderNetworks(data);
sidebar.find(".chan")
.last()
.trigger("click");
$("#connect")
.find(".btn")
.prop("disabled", false)
.end();
});
socket.on("network_changed", function(data) {
sidebar.find("#network-" + data.network).data("options", data.serverOptions);
});

View File

@ -0,0 +1,15 @@
"use strict";
const $ = require("jquery");
const socket = require("../socket");
const utils = require("../utils");
const sidebar = $("#sidebar");
socket.on("nick", function(data) {
const id = data.network;
const nick = data.nick;
const network = sidebar.find("#network-" + id).data("nick", nick);
if (network.find(".active").length) {
utils.setNick(nick);
}
});

View File

@ -0,0 +1,11 @@
"use strict";
const $ = require("jquery");
const socket = require("../socket");
socket.on("open", function(id) {
// Another client opened the channel, clear the unread counter
$("#sidebar").find(".chan[data-id='" + id + "'] .badge")
.removeClass("highlight")
.empty();
});

View File

@ -0,0 +1,17 @@
"use strict";
const $ = require("jquery");
const socket = require("../socket");
const sidebar = $("#sidebar");
socket.on("part", function(data) {
const chanMenuItem = sidebar.find(".chan[data-id='" + data.chan + "']");
// When parting from the active channel/query, jump to the network's lobby
if (chanMenuItem.hasClass("active")) {
chanMenuItem.parent(".network").find(".lobby").click();
}
chanMenuItem.remove();
$("#chan-" + data.chan).remove();
});

View File

@ -0,0 +1,18 @@
"use strict";
const $ = require("jquery");
const socket = require("../socket");
const sidebar = $("#sidebar");
socket.on("quit", function(data) {
const id = data.network;
sidebar.find("#network-" + id)
.remove()
.end();
const chan = sidebar.find(".chan")
.eq(0)
.trigger("click");
if (chan.length === 0) {
sidebar.find(".empty").show();
}
});

View File

@ -0,0 +1,9 @@
"use strict";
const socket = require("../socket");
const storage = require("../localStorage");
socket.on("sign-out", function() {
storage.remove("token");
location.reload();
});

View File

@ -0,0 +1,50 @@
"use strict";
const $ = require("jquery");
const socket = require("../socket");
const options = require("../options");
socket.on("sync_sort", function(data) {
// Syncs the order of channels or networks when they are reordered
if (options.ignoreSortSync) {
options.ignoreSortSync = false;
return; // Ignore syncing because we 'caused' it
}
const type = data.type;
const order = data.order;
if (type === "networks") {
const container = $(".networks");
$.each(order, function(index, value) {
const position = $(container.children()[index]);
if (position.data("id") === value) { // Network in correct place
return true; // No point in continuing
}
const network = container.find("#network-" + value);
$(network).insertBefore(position);
});
} else if (type === "channels") {
const network = $("#network-" + data.target);
$.each(order, function(index, value) {
if (index === 0) { // Shouldn't attempt to move lobby
return true; // same as `continue` -> skip to next item
}
const position = $(network.children()[index]); // Target channel at position
if (position.data("id") === value) { // Channel in correct place
return true; // No point in continuing
}
const channel = network.find(".chan[data-id=" + value + "]"); // Channel at position
$(channel).insertBefore(position);
});
}
});

View File

@ -0,0 +1,12 @@
"use strict";
const $ = require("jquery");
const socket = require("../socket");
const helpers_parse = require("../libs/handlebars/parse");
socket.on("topic", function(data) {
const topic = $("#chan-" + data.chan).find(".header .topic");
topic.html(helpers_parse(data.topic));
// .attr() is safe escape-wise but consider the capabilities of the attribute
topic.attr("title", data.topic);
});

View File

@ -0,0 +1,17 @@
"use strict";
const $ = require("jquery");
const socket = require("../socket");
const chat = $("#chat");
socket.on("users", function(data) {
const chan = chat.find("#chan-" + data.chan);
if (chan.hasClass("active")) {
socket.emit("names", {
target: data.chan
});
} else {
chan.data("needsNamesRefresh", true);
}
});

55
client/js/socket.js Normal file
View File

@ -0,0 +1,55 @@
"use strict";
const $ = require("jquery");
const io = require("socket.io-client");
const path = window.location.pathname + "socket.io/";
const socket = io({
transports: $(document.body).data("transports"),
path: path,
autoConnect: false,
reconnection: false
});
[
"connect_error",
"connect_failed",
"disconnect",
"error",
].forEach(function(e) {
socket.on(e, function(data) {
$("#loading-page-message").text("Connection failed: " + data);
$("#connection-error").addClass("shown").one("click", function() {
window.onbeforeunload = null;
window.location.reload();
});
// Disables sending a message by pressing Enter. `off` is necessary to
// cancel `inputhistory`, which overrides hitting Enter. `on` is then
// necessary to avoid creating new lines when hitting Enter without Shift.
// This is fairly hacky but this solution is not permanent.
$("#input").off("keydown").on("keydown", function(event) {
if (event.which === 13 && !event.shiftKey) {
event.preventDefault();
}
});
// Hides the "Send Message" button
$("#submit").remove();
console.error(data);
});
});
socket.on("connecting", function() {
$("#loading-page-message").text("Connecting…");
});
socket.on("connect", function() {
$("#loading-page-message").text("Finalizing connection…");
});
socket.on("authorized", function() {
$("#loading-page-message").text("Authorized, loading messages…");
});
module.exports = socket;

64
client/js/sorting.js Normal file
View File

@ -0,0 +1,64 @@
"use strict";
const $ = require("jquery");
const sidebar = $("#sidebar, #footer");
const socket = require("./socket");
const options = require("./options");
module.exports = function() {
sidebar.find(".networks").sortable({
axis: "y",
containment: "parent",
cursor: "move",
distance: 12,
items: ".network",
handle: ".lobby",
placeholder: "network-placeholder",
forcePlaceholderSize: true,
tolerance: "pointer", // Use the pointer to figure out where the network is in the list
update: function() {
const order = [];
sidebar.find(".network").each(function() {
const id = $(this).data("id");
order.push(id);
});
socket.emit(
"sort", {
type: "networks",
order: order
}
);
options.ignoreSortSync = true;
}
});
sidebar.find(".network").sortable({
axis: "y",
containment: "parent",
cursor: "move",
distance: 12,
items: ".chan:not(.lobby)",
placeholder: "chan-placeholder",
forcePlaceholderSize: true,
tolerance: "pointer", // Use the pointer to figure out where the channel is in the list
update: function(e, ui) {
const order = [];
const network = ui.item.parent();
network.find(".chan").each(function() {
const id = $(this).data("id");
order.push(id);
});
socket.emit(
"sort", {
type: "channels",
target: network.data("id"),
order: order
}
);
options.ignoreSortSync = true;
}
});
};

79
client/js/utils.js Normal file
View File

@ -0,0 +1,79 @@
"use strict";
const $ = require("jquery");
const chat = $("#chat");
const input = $("#input");
module.exports = {
clear,
confirmExit,
forceFocus,
move,
resetHeight,
setNick,
toggleNickEditor,
toggleNotificationMarkers
};
function resetHeight(element) {
element.style.height = element.style.minHeight;
}
// Triggering click event opens the virtual keyboard on mobile
// This can only be called from another interactive event (e.g. button click)
function forceFocus() {
input.trigger("click").focus();
}
function clear() {
chat.find(".active")
.find(".show-more").addClass("show").end()
.find(".messages .msg, .date-marker-container").remove();
}
function toggleNickEditor(toggle) {
$("#nick").toggleClass("editable", toggle);
$("#nick-value").attr("contenteditable", toggle);
}
function setNick(nick) {
// Closes the nick editor when canceling, changing channel, or when a nick
// is set in a different tab / browser / device.
toggleNickEditor(false);
$("#nick-value").text(nick);
}
const favicon = $("#favicon");
function toggleNotificationMarkers(newState) {
// Toggles the favicon to red when there are unread notifications
if (favicon.data("toggled") !== newState) {
var old = favicon.attr("href");
favicon.attr("href", favicon.data("other"));
favicon.data("other", old);
favicon.data("toggled", newState);
}
// Toggles a dot on the menu icon when there are unread notifications
$("#viewport .lt").toggleClass("notified", newState);
}
function confirmExit() {
if ($("body").hasClass("public")) {
window.onbeforeunload = function() {
return "Are you sure you want to navigate away from this page?";
};
}
}
function move(array, old_index, new_index) {
if (new_index >= array.length) {
let k = new_index - array.length;
while ((k--) + 1) {
this.push(undefined);
}
}
array.splice(new_index, 0, array.splice(old_index, 1)[0]);
return array;
}

127
client/js/webpush.js Normal file
View File

@ -0,0 +1,127 @@
"use strict";
const $ = require("jquery");
const storage = require("./localStorage");
const socket = require("./socket");
const pushNotificationsButton = $("#pushNotifications");
let clientSubscribed = null;
let applicationServerKey;
module.exports.configurePushNotifications = (subscribedOnServer, key) => {
applicationServerKey = key;
// If client has push registration but the server knows nothing about it,
// this subscription is broken and client has to register again
if (clientSubscribed === true && subscribedOnServer === false) {
pushNotificationsButton.attr("disabled", true);
navigator.serviceWorker.register("service-worker.js")
.then((registration) => registration.pushManager.getSubscription())
.then((subscription) => subscription && subscription.unsubscribe())
.then((successful) => {
if (successful) {
alternatePushButton().removeAttr("disabled");
}
});
}
};
if (isAllowedServiceWorkersHost()) {
$("#pushNotificationsHttps").hide();
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("service-worker.js").then((registration) => {
if (!registration.pushManager) {
return;
}
return registration.pushManager.getSubscription().then((subscription) => {
$("#pushNotificationsUnsupported").hide();
pushNotificationsButton
.removeAttr("disabled")
.on("click", onPushButton);
clientSubscribed = !!subscription;
if (clientSubscribed) {
alternatePushButton();
}
});
}).catch((err) => {
$("#pushNotificationsUnsupported span").text(err);
});
}
}
function onPushButton() {
pushNotificationsButton.attr("disabled", true);
navigator.serviceWorker.register("service-worker.js").then((registration) => {
registration.pushManager.getSubscription().then((existingSubscription) => {
if (existingSubscription) {
socket.emit("push:unregister");
return existingSubscription.unsubscribe();
}
return registration.pushManager.subscribe({
applicationServerKey: urlBase64ToUint8Array(applicationServerKey),
userVisibleOnly: true
}).then((subscription) => {
const rawKey = subscription.getKey ? subscription.getKey("p256dh") : "";
const key = rawKey ? window.btoa(String.fromCharCode.apply(null, new Uint8Array(rawKey))) : "";
const rawAuthSecret = subscription.getKey ? subscription.getKey("auth") : "";
const authSecret = rawAuthSecret ? window.btoa(String.fromCharCode.apply(null, new Uint8Array(rawAuthSecret))) : "";
socket.emit("push:register", {
token: storage.get("token"),
endpoint: subscription.endpoint,
keys: {
p256dh: key,
auth: authSecret
}
});
return true;
});
}).then((successful) => {
if (successful) {
alternatePushButton().removeAttr("disabled");
}
});
}).catch((err) => {
$("#pushNotificationsUnsupported span").text(err).show();
});
return false;
}
function alternatePushButton() {
const text = pushNotificationsButton.text();
return pushNotificationsButton
.text(pushNotificationsButton.data("text-alternate"))
.data("text-alternate", text);
}
function urlBase64ToUint8Array(base64String) {
const padding = "=".repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/-/g, "+")
.replace(/_/g, "/");
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
function isAllowedServiceWorkersHost() {
return location.protocol === "https:" || location.hostname === "localhost" || location.hostname === "127.0.0.1";
}

41
client/service-worker.js Normal file
View File

@ -0,0 +1,41 @@
// The Lounge - https://github.com/thelounge/lounge
/* global clients */
"use strict";
self.addEventListener("push", function(event) {
if (!event.data) {
return;
}
const payload = event.data.json();
if (payload.type === "notification") {
event.waitUntil(
self.registration.showNotification(payload.title, {
badge: "img/logo-64.png",
icon: "img/touch-icon-192x192.png",
body: payload.body,
timestamp: payload.timestamp,
})
);
}
});
self.addEventListener("notificationclick", function(event) {
event.notification.close();
event.waitUntil(clients.matchAll({
type: "window"
}).then(function(clientList) {
for (var i = 0; i < clientList.length; i++) {
var client = clientList[i];
if ("focus" in client) {
return client.focus();
}
}
if (clients.openWindow) {
return clients.openWindow(".");
}
}));
});

View File

@ -87,7 +87,7 @@ a:hover,
color: #00ff0e; color: #00ff0e;
} }
#sidebar .chan .name:after { #sidebar .chan .name::after {
background: linear-gradient(to right, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 1) 100%); background: linear-gradient(to right, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 1) 100%);
} }
@ -95,7 +95,7 @@ a:hover,
#sidebar .chan, #sidebar .chan,
#sidebar .sign-out, #sidebar .sign-out,
#chat .time, #chat .time,
#chat .count:before, #chat .count::before,
#sidebar .empty { #sidebar .empty {
color: #666; color: #666;
} }
@ -133,6 +133,12 @@ a:hover,
color: #666; color: #666;
} }
.tooltipped:after { .tooltipped::after {
font-family: Inconsolata-g, monospace; font-family: Inconsolata-g, monospace;
} }
/* Previews */
#chat .toggle-text {
line-height: initial;
}

View File

@ -31,7 +31,7 @@ body {
color: #ddd; color: #ddd;
} }
#windows .window:before { #windows .window::before {
background: #f4f4f4; background: #f4f4f4;
background-image: linear-gradient(#f4f4f4, #ececec); background-image: linear-gradient(#f4f4f4, #ececec);
border-bottom: 1px solid #d7d7d7; border-bottom: 1px solid #d7d7d7;

View File

@ -49,7 +49,7 @@ body {
/* Borders */ /* Borders */
#chat .from, #chat .from,
#windows .header, #windows .header,
#chat .user-mode:before, #chat .user-mode::before,
#chat .sidebar { #chat .sidebar {
border-color: #2a323d; border-color: #2a323d;
} }
@ -64,10 +64,6 @@ body {
color: #b0bacf; color: #b0bacf;
} }
#chat .user:hover {
color: #fefefe;
}
#chat.colored-nicks .user.color-1 { color: #f7adf7; } #chat.colored-nicks .user.color-1 { color: #f7adf7; }
#chat.colored-nicks .user.color-2 { color: #abf99f; } #chat.colored-nicks .user.color-2 { color: #abf99f; }
#chat.colored-nicks .user.color-3 { color: #86efdc; } #chat.colored-nicks .user.color-3 { color: #86efdc; }
@ -105,10 +101,6 @@ body {
color: #428bca; color: #428bca;
} }
#chat button:hover {
opacity: 1;
}
/* Increase contrast of some IRC colors */ /* Increase contrast of some IRC colors */
.irc-fg2 { color: #0074d9; } .irc-fg2 { color: #0074d9; }
.irc-fg5 { color: #e969a7; } .irc-fg5 { color: #e969a7; }
@ -151,24 +143,19 @@ body {
} }
/* Notification dot on the top right corner of the menu icon */ /* Notification dot on the top right corner of the menu icon */
#viewport .lt:after { #viewport .lt::after {
border-color: #333c4a; border-color: #333c4a;
} }
#chat .unread-marker, #chat .unread-marker-text::before {
#chat .date-marker {
opacity: 1;
}
#chat .unread-marker-text:before {
background-color: #333c4a; background-color: #333c4a;
} }
#chat .date-marker:before { #chat .date-marker::before {
border-color: #97ea70; border-color: #97ea70;
} }
#chat .date-marker-text:before { #chat .date-marker-text::before {
background-color: #333c4a; background-color: #333c4a;
color: #97ea70; color: #97ea70;
} }
@ -212,16 +199,14 @@ body {
color: #84ce88 !important; color: #84ce88 !important;
} }
/* Embeds */ #chat table.channel-list td {
#chat .toggle-content, color: #ccc;
#chat .toggle-button {
background: #242a33;
color: #f3f3f3;
} }
#chat .toggle-content img { /* Embeds */
float: left; #chat .toggle-content {
margin-right: .5em; background: #242a33;
color: #f3f3f3;
} }
#chat .toggle-content .body { #chat .toggle-content .body {
@ -240,17 +225,23 @@ body {
#chat-container ::-moz-placeholder { #chat-container ::-moz-placeholder {
color: #99a2b4; color: #99a2b4;
opacity: .5; opacity: 0.5;
} }
#chat-container ::-webkit-input-placeholder { #chat-container ::-webkit-input-placeholder {
color: #99a2b4; color: #99a2b4;
opacity: .5; opacity: 0.5;
} }
#chat-container :-ms-input-placeholder { #chat-container :-ms-input-placeholder {
color: #99a2b4; color: #99a2b4;
opacity: .5; opacity: 0.5;
} }
/* End form elements */ /* End form elements */
@media (min-width: 480px) {
#chat .from::after {
background: linear-gradient(to right, rgba(51, 60, 74, 0.5) 0%, rgba(51, 60, 74, 1) 100%);
}
}

View File

@ -54,12 +54,12 @@ body {
background: #2b2b2b; background: #2b2b2b;
} }
#sidebar .chan .name:after { #sidebar .chan .name::after {
background: linear-gradient(to right, rgba(43, 43, 43, 0) 0%, rgba(43, 43, 43, 1) 100%); background: linear-gradient(to right, rgba(43, 43, 43, 0) 0%, rgba(43, 43, 43, 1) 100%);
} }
#footer { #footer {
background: #33332f; background: #333;
border-top: 1px solid #000; border-top: 1px solid #000;
} }
@ -75,7 +75,7 @@ body {
/* Borders */ /* Borders */
#chat .from, #chat .from,
#windows .header, #windows .header,
#chat .user-mode:before, #chat .user-mode::before,
#chat .sidebar { #chat .sidebar {
border-color: #333; border-color: #333;
} }
@ -90,10 +90,6 @@ body {
color: #bc8cbc; color: #bc8cbc;
} }
#chat .user:hover {
color: #dcdccc;
}
#chat.colored-nicks .user.color-1 { color: #f7adf7; } #chat.colored-nicks .user.color-1 { color: #f7adf7; }
#chat.colored-nicks .user.color-2 { color: #abf99f; } #chat.colored-nicks .user.color-2 { color: #abf99f; }
#chat.colored-nicks .user.color-3 { color: #86efdc; } #chat.colored-nicks .user.color-3 { color: #86efdc; }
@ -131,10 +127,6 @@ body {
color: #8c8cbc; color: #8c8cbc;
} }
#chat button:hover {
opacity: 1;
}
/* Increase contrast of some IRC colors */ /* Increase contrast of some IRC colors */
.irc-fg2 { color: #1b94ff; } .irc-fg2 { color: #1b94ff; }
.irc-fg5 { color: #e969a7; } .irc-fg5 { color: #e969a7; }
@ -177,24 +169,19 @@ body {
} }
/* Notification dot on the top right corner of the menu icon */ /* Notification dot on the top right corner of the menu icon */
#viewport .lt:after { #viewport .lt::after {
border-color: #3f3f3f; border-color: #3f3f3f;
} }
#chat .unread-marker, #chat .unread-marker-text::before {
.date-marker {
opacity: 1;
}
#chat .unread-marker-text:before {
background-color: #3f3f3f; background-color: #3f3f3f;
} }
#chat .date-marker:before { #chat .date-marker::before {
border-color: #97ea70; border-color: #97ea70;
} }
#chat .date-marker-text:before { #chat .date-marker-text::before {
background-color: #3f3f3f; background-color: #3f3f3f;
color: #97ea70; color: #97ea70;
} }
@ -238,16 +225,15 @@ body {
color: #8cd0d3 !important; color: #8cd0d3 !important;
} }
/* Embeds */ #chat table.channel-list td {
#chat .toggle-content, color: #ccc;
#chat .toggle-button {
background: #93b3a3;
color: #dcdccc;
} }
#chat .toggle-content img { /* Previews */
float: left;
margin-right: .5em; #chat .toggle-content {
background: #333;
color: #dcdccc;
} }
#chat .toggle-content .body { #chat .toggle-content .body {
@ -266,17 +252,23 @@ body {
#chat-container ::-moz-placeholder { #chat-container ::-moz-placeholder {
color: #d2d39b; color: #d2d39b;
opacity: .5; opacity: 0.5;
} }
#chat-container ::-webkit-input-placeholder { #chat-container ::-webkit-input-placeholder {
color: #d2d39b; color: #d2d39b;
opacity: .5; opacity: 0.5;
} }
#chat-container :-ms-input-placeholder { #chat-container :-ms-input-placeholder {
color: #d2d39b; color: #d2d39b;
opacity: .5; opacity: 0.5;
} }
/* End form elements */ /* End form elements */
@media (min-width: 480px) {
#chat .from::after {
background: linear-gradient(to right, rgba(63, 63, 63, 0.5) 0%, rgba(63, 63, 63, 1) 100%);
}
}

View File

@ -1,2 +1,6 @@
<span role="button" class="user {{colorClass from}}" data-name="{{from}}">{{mode}}{{from}}</span> {{> ../user_name nick=from}}
<span class="action-text">{{{parse text}}}</span> <span class="text">{{{parse text}}}</span>
{{#each previews}}
<div class="preview" data-url="{{link}}"></div>
{{/each}}

View File

@ -0,0 +1,18 @@
<table class="ban-list">
<thead>
<tr>
<th class="hostmask">Banned</th>
<th class="banned_by">Banned By</th>
<th class="banned_at">Banned At</th>
</tr>
</thead>
<tbody>
{{#each bans}}
<tr>
<td class="hostmask">{{hostmask}}</td>
<td class="banned_by">{{{parse banned_by}}}</td>
<td class="banned_at">{{{localetime banned_at}}}</td>
</tr>
{{/each}}
</tbody>
</table>

View File

@ -1,2 +1,2 @@
<span role="button" class="user {{colorClass from}}" data-name="{{from}}">{{from}}</span> {{> ../user_name nick=from}}
<b>{{ctcpType}}</b> {{{parse ctcpMessage}}} <b>{{ctcpType}}</b> <span class="ctcp-message">{{{parse ctcpMessage}}}</span>

View File

@ -1,9 +1,9 @@
<span role="button" class="user {{colorClass from}}" data-name="{{from}}">{{from}}</span> {{> ../user_name nick=from}}
invited invited
{{#if invitedYou}} {{#if invitedYou}}
you you
{{else}} {{else}}
<span role="button" class="user {{colorClass invited}}" data-name="{{invited}}">{{invited}}</span> {{> ../user_name nick=invited}}
{{/if}} {{/if}}
to to
{{{parse channel}}} {{{parse channel}}}

View File

@ -1,3 +1,3 @@
<span role="button" class="user {{colorClass from}}" data-name="{{from}}">{{mode}}{{from}}</span> {{> ../user_name nick=from}}
<i class="hostmask">({{hostmask}})</i> <i class="hostmask">({{hostmask}})</i>
has joined the channel has joined the channel

View File

@ -1,6 +1,6 @@
<span role="button" class="user {{colorClass from}}" data-name="{{from}}">{{mode}}{{from}}</span> {{> ../user_name nick=from}}
has kicked has kicked
<span role="button" class="user {{colorClass target}}" data-name="{{target}}">{{target}}</span> {{> ../user_name nick=target mode=""}}
{{#if text}} {{#if text}}
<i class="part-reason">({{{parse text}}})</i> <i class="part-reason">({{{parse text}}})</i>
{{/if}} {{/if}}

View File

@ -1,3 +1,3 @@
<span role="button" class="user {{colorClass from}}" data-name="{{from}}">{{mode}}{{from}}</span> {{> ../user_name nick=from}}
sets mode sets mode
{{{parse text}}} {{{parse text}}}

View File

@ -1,3 +1,3 @@
<span role="button" class="user {{colorClass from}}" data-name="{{from}}">{{mode}}{{from}}</span> {{> ../user_name nick=from}}
is now known as is now known as
<span role="button" class="user {{colorClass new_nick}}" data-name="{{new_nick}}">{{mode}}{{new_nick}}</span> {{> ../user_name nick=new_nick}}

View File

@ -1,4 +1,4 @@
<span role="button" class="user {{colorClass from}}" data-name="{{from}}">{{mode}}{{from}}</span> {{> ../user_name nick=from}}
<i class="hostmask">({{hostmask}})</i> <i class="hostmask">({{hostmask}})</i>
has left the channel has left the channel
{{#if text}} {{#if text}}

View File

@ -1,4 +1,4 @@
<span role="button" class="user {{colorClass from}}" data-name="{{from}}">{{mode}}{{from}}</span> {{> ../user_name nick=from}}
<i class="hostmask">({{hostmask}})</i> <i class="hostmask">({{hostmask}})</i>
has quit has quit
{{#if text}} {{#if text}}

View File

@ -1,5 +1,5 @@
{{#if from}} {{#if from}}
<span role="button" class="user {{colorClass from}}" data-name="{{from}}">{{mode}}{{from}}</span> {{> ../user_name nick=from}}
has changed the topic to: has changed the topic to:
{{else}} {{else}}
The topic is: The topic is:

View File

@ -1,3 +1,3 @@
Topic set by Topic set by
<span role="button" class="user {{colorClass nick}}" data-name="{{nick}}">{{mode}}{{nick}}</span> {{> ../user_name}}
on {{localetime when}} on {{localetime when}}

View File

@ -1,41 +1,41 @@
<div> <div>
<span role="button" class="user {{colorClass whois.nick}}" data-name="{{whois.nick}}">{{whois.nick}}</span> {{> ../user_name nick=whois.nick}}
<i class="hostmask">({{whois.user}}@{{whois.host}})</i>: <i class="hostmask">({{whois.user}}@{{whois.host}})</i>:
<b>{{whois.real_name}}</b> <b>{{whois.real_name}}</b>
</div> </div>
{{#if whois.account}} {{#if whois.account}}
<div> <div>
<span role="button" class="user {{colorClass whois.nick}}" data-name="{{whois.nick}}">{{whois.nick}}</span> {{> ../user_name nick=whois.nick}}
is logged in as <b>{{whois.account}}</b> is logged in as <b>{{whois.account}}</b>
</div> </div>
{{/if}} {{/if}}
{{#if whois.channels}} {{#if whois.channels}}
<div> <div>
<span role="button" class="user {{colorClass whois.nick}}" data-name="{{whois.nick}}">{{whois.nick}}</span> {{> ../user_name nick=whois.nick}}
is on the following channels: {{{parse whois.channels}}} is on the following channels: {{{parse whois.channels}}}
</div> </div>
{{/if}} {{/if}}
{{#if whois.server}} {{#if whois.server}}
<div> <div>
<span role="button" class="user {{colorClass whois.nick}}" data-name="{{whois.nick}}">{{whois.nick}}</span> {{> ../user_name nick=whois.nick}}
is connected to {{whois.server}} <i>({{whois.server_info}})</i> is connected to {{whois.server}} <i>({{whois.server_info}})</i>
</div> </div>
{{/if}} {{/if}}
{{#if whois.secure}} {{#if whois.secure}}
<div> <div>
<span role="button" class="user {{colorClass whois.nick}}" data-name="{{whois.nick}}">{{whois.nick}}</span> {{> ../user_name nick=whois.nick}}
is using a secure connection is using a secure connection
</div> </div>
{{/if}} {{/if}}
{{#if whois.away}} {{#if whois.away}}
<div> <div>
<span role="button" class="user {{colorClass whois.nick}}" data-name="{{whois.nick}}">{{whois.nick}}</span> {{> ../user_name nick=whois.nick}}
is away <i>({{whois.away}})</i> is away <i>({{whois.away}})</i>
</div> </div>
{{/if}} {{/if}}
{{#if whois.idle}} {{#if whois.idle}}
<div> <div>
<span role="button" class="user {{colorClass whois.nick}}" data-name="{{whois.nick}}">{{whois.nick}}</span> {{> ../user_name nick=whois.nick}}
has been idle since {{localetime whois.idleTime}}. has been idle since {{localetime whois.idleTime}}.
</div> </div>
{{/if}} {{/if}}

View File

@ -1,5 +1,5 @@
{{#each channels}} {{#each channels}}
<div data-id="{{id}}" data-target="#chan-{{id}}" data-title="{{name}}" class="chan {{type}}"> <div data-id="{{id}}" data-target="#chan-{{id}}" data-title="{{name}}" class="chan {{type}} chan-{{slugify name}}">
<span class="badge{{#if highlight}} highlight{{/if}}">{{#if unread}}{{roundBadgeNumber unread}}{{/if}}</span> <span class="badge{{#if highlight}} highlight{{/if}}">{{#if unread}}{{roundBadgeNumber unread}}{{/if}}</span>
<button class="close" aria-label="Close"></button> <button class="close" aria-label="Close"></button>
<span class="name" title="{{name}}">{{name}}</span> <span class="name" title="{{name}}">{{name}}</span>

View File

@ -13,14 +13,20 @@
</div> </div>
<div class="chat"> <div class="chat">
<div class="show-more {{#equal messages.length 100}}show{{/equal}}"> <div class="show-more {{#equal messages.length 100}}show{{/equal}}">
<button class="show-more-button" data-id="{{id}}"> <button class="show-more-button" data-id="{{id}}">Show older messages</button>
Show older messages
</button>
</div> </div>
<div class="messages"></div> <div class="messages"></div>
</div> </div>
{{#equal type "channel"}}
<aside class="sidebar"> <aside class="sidebar">
<div class="users"></div> <div class="users">
<div class="count">
<input type="search" class="search" aria-label="Search among the user list" tabindex="-1">
</div>
<div class="names names-filtered"></div>
<div class="names names-original"></div>
</div>
</aside> </aside>
{{/equal}}
</div> </div>
{{/each}} {{/each}}

View File

@ -1,3 +1,5 @@
<div class="date-marker"> <div class="date-marker-container tooltipped tooltipped-s" data-timestamp="{{msgDate}}" aria-label="{{localedate msgDate}}">
<span class="date-marker-text" data-date="{{localedate msgDate}}"></span> <div class="date-marker">
<span class="date-marker-text" data-label="{{friendlydate msgDate}}"></span>
</div>
</div> </div>

View File

@ -0,0 +1,21 @@
<button class="close-btn" aria-label="Close"></button>
{{#if hasPreviousImage}}
<button class="previous-image-btn" aria-label="Previous image"></button>
{{/if}}
{{#if hasNextImage}}
<button class="next-image-btn" aria-label="Next image"></button>
{{/if}}
<a class="image-link" href="{{link}}" target="_blank">
<img src="{{image}}" alt="Preview of {{link}}">
</a>
<a class="btn open-btn" href="{{link}}" target="_blank">
{{#equal type "image"}}
Open image
{{else}}
Visit page
{{/equal}}
</a>

View File

@ -1,6 +1,9 @@
"use strict";
module.exports = { module.exports = {
actions: { actions: {
action: require("./actions/action.tpl"), action: require("./actions/action.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"),
invite: require("./actions/invite.tpl"), invite: require("./actions/invite.tpl"),
@ -22,9 +25,15 @@ module.exports = {
date_marker: require("./date-marker.tpl"), date_marker: require("./date-marker.tpl"),
msg: require("./msg.tpl"), msg: require("./msg.tpl"),
msg_action: require("./msg_action.tpl"), msg_action: require("./msg_action.tpl"),
msg_condensed_toggle: require("./msg_condensed_toggle.tpl"),
msg_condensed: require("./msg_condensed.tpl"),
msg_preview: require("./msg_preview.tpl"),
msg_preview_toggle: require("./msg_preview_toggle.tpl"),
msg_unhandled: require("./msg_unhandled.tpl"), msg_unhandled: require("./msg_unhandled.tpl"),
network: require("./network.tpl"), network: require("./network.tpl"),
toggle: require("./toggle.tpl"), image_viewer: require("./image_viewer.tpl"),
unread_marker: require("./unread_marker.tpl"), unread_marker: require("./unread_marker.tpl"),
user: require("./user.tpl"), user: require("./user.tpl"),
user_filtered: require("./user_filtered.tpl"),
user_name: require("./user_name.tpl"),
}; };

View File

@ -1,23 +1,17 @@
<div class="msg {{type}}{{#if self}} self{{/if}}{{#if highlight}} highlight{{/if}}" id="msg-{{id}}" data-time="{{time}}"> <div class="msg {{type}}{{#if self}} self{{/if}}{{#if highlight}} highlight{{/if}}" id="msg-{{id}}" data-time="{{time}}" data-from="{{from}}">
<span class="time" title="{{localetime time}}"> <span class="time tooltipped tooltipped-e" aria-label="{{localetime time}}">
{{tz time}} {{tz time}}
</span> </span>
<span class="from"> <span class="from">
{{#if from}} {{#if from}}
<span role="button" class="user {{colorClass from}}" data-name="{{from}}">{{mode}}{{from}}</span> {{> user_name nick=from}}
{{/if}} {{/if}}
</span> </span>
{{#equal type "toggle"}} <span class="content">
<span class="text">
<div class="force-newline">
<button id="toggle-{{id}}" class="toggle-button" aria-label="Toggle prefetched media">···</button>
</div>
{{#if toggle}}
{{> toggle}}
{{/if}}
</span>
{{else}}
<span class="text">{{{parse text}}}</span> <span class="text">{{{parse text}}}</span>
{{/equal}}
{{#each previews}}
<div class="preview" data-url="{{link}}"></div>
{{/each}}
</span> </span>
</div> </div>

View File

@ -1,7 +1,8 @@
<div class="msg {{type}}{{#if self}} self{{/if}}{{#if highlight}} highlight{{/if}}" id="msg-{{id}}" data-time="{{time}}"> <div class="msg {{type}}{{#if self}} self{{/if}}{{#if highlight}} highlight{{/if}}"
<span class="time" title="{{localetime time}}"> data-type="{{type}}" id="msg-{{id}}" data-time="{{time}}">
<span class="time tooltipped tooltipped-e" aria-label="{{localetime time}}">
{{tz time}} {{tz time}}
</span> </span>
<span class="from"></span> <span class="from"></span>
<span class="text"></span> <span class="content"></span>
</div> </div>

View File

@ -0,0 +1,7 @@
<div class="msg condensed closed" data-time="{{time}}">
<span class="time">{{tz time}}</span>
<span class="from"></span>
<span class="content">
<span class="condensed-text"></span>
</span>
</div>

View File

@ -0,0 +1 @@
<button class="toggle-button" aria-label="Toggle status messages"></button>

Some files were not shown because too many files have changed in this diff Show More