diff --git a/.editorconfig b/.editorconfig index 7907cdff..ac4d682b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -18,7 +18,3 @@ trim_trailing_whitespace = false [*.{json,yml}] indent_style = space indent_size = 2 - -[.eslintrc] -indent_style = space -indent_size = 2 diff --git a/.eslintrc.yml b/.eslintrc.yml index d6b6a59e..e5486756 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -9,39 +9,53 @@ env: node: true rules: + arrow-body-style: 2 + arrow-parens: [2, always] + arrow-spacing: 2 block-scoped-var: 2 block-spacing: [2, always] brace-style: [2, 1tbs] comma-dangle: 0 curly: [2, all] + dot-location: [2, property] dot-notation: 2 + eol-last: 2 eqeqeq: 2 handle-callback-err: 2 - indent: [2, tab, { "MemberExpression": 1 }] + indent: [2, tab] key-spacing: [2, {beforeColon: false, afterColon: true}] keyword-spacing: [2, {before: true, after: true}] linebreak-style: [2, unix] - no-compare-neg-zero: 2 + no-catch-shadow: 2 + no-confusing-arrow: 2 no-console: 0 no-control-regex: 0 + no-duplicate-imports: 2 no-else-return: 2 no-implicit-globals: 2 no-multi-spaces: 2 + no-multiple-empty-lines: [2, { "max": 1 }] no-shadow: 2 no-template-curly-in-string: 2 no-trailing-spaces: 2 no-unsafe-negation: 2 - no-useless-escape: 2 + no-useless-computed-key: 2 no-useless-return: 2 object-curly-spacing: [2, never] + padded-blocks: [2, never] + prefer-const: 2 quote-props: [2, as-needed] quotes: [2, double, avoid-escape] + semi-style: [2, last] semi: [2, always] space-before-blocks: 2 space-before-function-paren: [2, never] + space-in-parens: [2, never] space-infix-ops: 2 spaced-comment: [2, always] strict: 2 + template-curly-spacing: 2 + yoda: 2 globals: log: false diff --git a/.gitignore b/.gitignore index 96ca16d3..3c7c410b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ npm-debug.log +package-lock.json .nyc_output/ coverage/ diff --git a/.lounge_home b/.lounge_home new file mode 100644 index 00000000..86428c50 --- /dev/null +++ b/.lounge_home @@ -0,0 +1 @@ +~/.lounge diff --git a/.npmignore b/.npmignore index e232345b..a1b99df2 100644 --- a/.npmignore +++ b/.npmignore @@ -2,19 +2,11 @@ # npm-debug.log and node_modules/ are ignored by default. # See https://docs.npmjs.com/misc/developers#keeping-files-out-of-your-package +.* client/js/bundle.vendor.js.map client/views/ coverage/ scripts/ test/ -.editorconfig -.eslintignore -.eslintrc.yml -.gitattributes -.gitignore -.nycrc -.npmignore -.stylelintrc -.travis.yml appveyor.yml webpack.config.js diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..1dab4ed4 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +save-exact = true diff --git a/.nycrc b/.nycrc index 998e5bac..99b69780 100644 --- a/.nycrc +++ b/.nycrc @@ -3,8 +3,8 @@ "exclude": [ "client/js/bundle.js", "client/js/bundle.vendor.js", - "coverage/", - "test/" + "test/", + "webpack.config.js" ], "reporter": [ "lcov", diff --git a/.stylelintrc b/.stylelintrc deleted file mode 100644 index cabb1fda..00000000 --- a/.stylelintrc +++ /dev/null @@ -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" - } -} diff --git a/.stylelintrc.yml b/.stylelintrc.yml new file mode 100644 index 00000000..558be353 --- /dev/null +++ b/.stylelintrc.yml @@ -0,0 +1,8 @@ +extends: stylelint-config-standard + +ignoreFiles: + - coverage/**/*.css + - client/css/bootstrap.css + +rules: + indentation: tab diff --git a/.travis.yml b/.travis.yml index e8f8e4fa..f45869a9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,13 +1,13 @@ language: node_js node_js: -- 7 # Current stable +- 8 # Current stable (Active LTS from 2017-10-01) - 6 # Active LTS until 2018-04-18 -- 4 # Active LTS until 2017-04-01 +- 4 # Maintenance LTS until 2018-04-01 matrix: fast_finish: true 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 cache: @@ -29,7 +29,7 @@ deploy: api_key: secure: I9iN31GWI+Mz0xPw81N7qh1M6uidB+3BmiPUXt8QigX45zwp9EhvfZ0U/AIdUyQwzK2RK1zLRQSt+2/1jyeVi+U+AAsRRmaAUx8iqKaQPAkPnQtElolgRP04WSgo7fvNejfM7zS939bQNKG3RlSm04yPgu+ke2igf799p2bpFe2LtyoEeIiUfrUkBiMSpMguN9XF8a7jqCyIouTKjXHR24RmzJ9r7ZoMV27yQauS7XlD81bontzNRZxTytDKdJpZ+sxGIT9mbbtM4LUFX8MeNe3p/bjWavEhrO0ZIpkbOfS/L/w1375YDoNPXxCs288lnGUH+NbGNAEfn+BTz8cmUp7jI7QWR/kNACPeopdAX4OdZxT8wfQcfQZrfCuSpKciOMC7vGgPpQqjQ61t1RKcKs9VUnwC0SwWjyo8LlzkFKnP1ks0eDGYsSoPLdpC9+76UmePkQdxMhscO8TOgkOCcsTMLiyt6ABGOGKu2iE5SsjUYtPiSiRzSBAQENoO560+xBSVTKwqvvhzUAIt4AuAQSgsFjAylDdyzKoObHX12hBdALrqSOOSVwwIQ5/jTgNAsilURHo7KPD407PhRnLOsvumL0qg4sr9S1hjuUKnNla5dg9GY8FVjJ+b2t0A2vgfG1pR1e3vrJRXrpkfRorhmjvKAk2o5you5pQ1Itty7rM= on: - node: 7 + node: 8 condition: "$BUILD_ENV = production" tags: true repo: thelounge/lounge diff --git a/CHANGELOG.md b/CHANGELOG.md index 9171a3e0..70635487 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,6 @@ # Change Log All notable changes to this project will be documented in this file. -This project adheres to [Semantic Versioning](http://semver.org/). +## 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 and +- 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: + +The Lounge - Emoji fuzzy-matching + +### 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 `` 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)     ![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 -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. @@ -84,7 +452,7 @@ On the website: ## 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. @@ -121,7 +489,7 @@ On the website: ## 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! diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f3da308a..94623294 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,12 +3,6 @@ Welcome to The Lounge, it's great to have you here! We thank you in advance for 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 - Look at the [open and closed diff --git a/README.md b/README.md index 2ece5444..27b4cf49 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,25 @@ # 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) [![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) [![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). <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> 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) - 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` diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 00000000..1fbda01d --- /dev/null +++ b/SUPPORT.md @@ -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. diff --git a/appveyor.yml b/appveyor.yml index e36f70f2..25eba312 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -7,6 +7,9 @@ version: "{build}" # Do not build on tags (GitHub only) skip_tags: true +# Do not build feature branch with open pull requests +skip_branch_with_pr: true + environment: nodejs_version: '4' diff --git a/client/.eslintrc.yml b/client/.eslintrc.yml deleted file mode 100644 index cb4e55ff..00000000 --- a/client/.eslintrc.yml +++ /dev/null @@ -1,3 +0,0 @@ ---- -parserOptions: - sourceType: module diff --git a/client/css/style.css b/client/css/style.css index 6c5ac484..d2a7e9a3 100644 --- a/client/css/style.css +++ b/client/css/style.css @@ -39,6 +39,11 @@ body { color: #222; font: 16px Lato, sans-serif; margin: 0; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + cursor: default; /** * Disable pull-to-refresh on mobile that conflicts with scrolling the message list. @@ -48,12 +53,24 @@ body { } a { - transition: opacity .2s; + transition: opacity 0.2s; } a:hover { text-decoration: none; - opacity: .8; + opacity: 0.8; +} + +/** + * From Normalize. See https://github.com/thelounge/lounge/pull/1217 + * 1. Remove the bottom border in Chrome 57- and Firefox 39-. + * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. + */ + +abbr[title] { + border-bottom: none; /* 1 */ + text-decoration: underline; /* 2 */ + text-decoration: underline dotted; /* 2 */ } h1, @@ -118,115 +135,125 @@ kbd { margin-bottom: 10px; padding: 9px 17px; text-transform: uppercase; - transition: background .2s, border-color .2s, color .2s; + transition: background 0.2s, border-color 0.2s, color 0.2s; word-spacing: 3px; + cursor: pointer; /* This is useful for `<button>` elements */ } .btn:disabled, .btn:hover { background: #84ce88; color: #fff; + opacity: 1; } .btn:active { box-shadow: none; - opacity: .8; + opacity: 0.8; } .btn:disabled { - opacity: .6; + opacity: 0.6; } .container { margin: 80px auto; max-width: 480px; - overflow: hidden; - overflow-y: auto; - -webkit-overflow-scrolling: touch; - padding: 0 30px; } ::-moz-placeholder { - color: rgba(0, 0, 0, .35); + color: rgba(0, 0, 0, 0.35); opacity: 1; } ::-webkit-input-placeholder { - color: rgba(0, 0, 0, .35); + color: rgba(0, 0, 0, 0.35); } :-ms-input-placeholder { - color: rgba(0, 0, 0, .35) !important; + color: rgba(0, 0, 0, 0.35) !important; +} + +#help, +#windows .header .title, +#windows .header .topic, +#chat .messages { + -webkit-user-select: text; + -moz-user-select: text; + -ms-user-select: text; + user-select: text; + cursor: text; } /* Icons */ -#viewport .lt:before, -#viewport .rt:before, -#chat button.menu:before, -#sidebar .chan:before, -#chat .title:before, +#viewport .lt::before, +#viewport .rt::before, +#chat button.menu::before, +#sidebar .chan::before, +#chat .title::before, #footer .icon, -#chat .count:before, -#settings #play:before, -#form #cycle-nicks:before, -#form #submit:before, -#chat .invite .from:before, -#chat .join .from:before, -#chat .kick .from:before, -#chat .part .from:before, -#chat .quit .from:before, -#chat .topic .from:before, -#chat .mode .from:before, -#chat .ctcp .from:before, -#chat .whois .from:before, -#chat .nick .from:before, -#chat .action .from:before, -.context-menu-item:before, -#nick button:before { +#chat .count::before, +#settings .extra-help, +#settings #play::before, +#form #submit::before, +#chat .invite .from::before, +#chat .join .from::before, +#chat .kick .from::before, +#chat .part .from::before, +#chat .quit .from::before, +#chat .topic .from::before, +#chat .mode .from::before, +#chat .ctcp .from::before, +#chat .whois .from::before, +#chat .nick .from::before, +#chat .action .from::before, +#chat .toggle-button::after, +.context-menu-item::before, +#nick button::before, +#image-viewer .previous-image-btn::before, +#image-viewer .next-image-btn::before { font: normal normal normal 14px/1 FontAwesome; font-size: inherit; /* Can't have font-size inherit on line above, so need to override */ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } -#viewport .lt:before { content: "\f0c9"; /* http://fontawesome.io/icon/bars/ */ } -#viewport .rt:before { content: "\f0c0"; /* http://fontawesome.io/icon/users/ */ } -#chat button.menu:before { content: "\f142"; /* http://fontawesome.io/icon/ellipsis-v/ */ } +#viewport .lt::before { content: "\f0c9"; /* http://fontawesome.io/icon/bars/ */ } +#viewport .rt::before { content: "\f0c0"; /* http://fontawesome.io/icon/users/ */ } +#chat button.menu::before { content: "\f142"; /* http://fontawesome.io/icon/ellipsis-v/ */ } -.context-menu-user:before { content: "\f007"; /* http://fontawesome.io/icon/user/ */ } -.context-menu-chan:before { content: "\f0f6"; /* http://fontawesome.io/icon/file-text-o/ */ } -.context-menu-close:before { content: "\f00d"; /* http://fontawesome.io/icon/times/ */ } +.context-menu-user::before { content: "\f007"; /* http://fontawesome.io/icon/user/ */ } +.context-menu-chan::before { content: "\f0f6"; /* http://fontawesome.io/icon/file-text-o/ */ } +.context-menu-close::before { content: "\f00d"; /* http://fontawesome.io/icon/times/ */ } -#sidebar .chan.lobby:before, -#chat .lobby .title:before { content: "\f0a0"; /* http://fontawesome.io/icon/hdd-o/ */ } +#sidebar .chan.lobby::before, +#chat .lobby .title::before { content: "\f0a0"; /* http://fontawesome.io/icon/hdd-o/ */ } -#sidebar .chan.query:before, -#chat .query .title:before { content: "\f0e6"; /* http://fontawesome.io/icon/comments-o/ */ } +#sidebar .chan.query::before, +#chat .query .title::before { content: "\f0e6"; /* http://fontawesome.io/icon/comments-o/ */ } -#sidebar .chan.channel:before, -#chat .channel .title:before { content: "\f0f6"; /* http://fontawesome.io/icon/file-text-o/ */ } +#sidebar .chan.channel::before, +#chat .channel .title::before { content: "\f0f6"; /* http://fontawesome.io/icon/file-text-o/ */ } -#sidebar .chan.special:before, -#chat .special .title:before { content: "\f03a"; /* http://fontawesome.io/icon/list/ */ } +#sidebar .chan.special::before, +#chat .special .title::before { content: "\f03a"; /* http://fontawesome.io/icon/list/ */ } -#footer .sign-in:before { content: "\f023"; /* http://fontawesome.io/icon/lock/ */ } -#footer .connect:before { content: "\f067"; /* http://fontawesome.io/icon/plus/ */ } -#footer .settings:before { content: "\f013"; /* http://fontawesome.io/icon/cog/ */ } -#footer .help:before { content: "\f059"; /* http://fontawesome.io/icon/question/ */ } -#footer .sign-out:before { content: "\f011"; /* http://fontawesome.io/icon/power-off/ */ } +#footer .sign-in::before { content: "\f023"; /* http://fontawesome.io/icon/lock/ */ } +#footer .connect::before { content: "\f067"; /* http://fontawesome.io/icon/plus/ */ } +#footer .settings::before { content: "\f013"; /* http://fontawesome.io/icon/cog/ */ } +#footer .help::before { content: "\f059"; /* http://fontawesome.io/icon/question/ */ } +#footer .sign-out::before { content: "\f011"; /* http://fontawesome.io/icon/power-off/ */ } -#form #cycle-nicks:before { content: "\f007"; /* http://fontawesome.io/icon/user/ */ } +#form #submit::before { content: "\f1d8"; /* http://fontawesome.io/icon/paper-plane/ */ } -#form #submit:before { content: "\f1d8"; /* http://fontawesome.io/icon/paper-plane/ */ } - -#chat .invite .from:before { +#chat .invite .from::before { content: "\f003"; /* http://fontawesome.io/icon/envelope-o/ */ color: #2ecc40; } -#chat .part .from:before, -#chat .quit .from:before { +#chat .part .from::before, +#chat .quit .from::before { content: "\f08b"; /* http://fontawesome.io/icon/sign-out/ */ color: #ff4136; display: inline-block; @@ -234,45 +261,55 @@ kbd { transform: rotate(180deg); } -#chat .topic .from:before { +#chat .topic .from::before { content: "\f0a1"; /* http://fontawesome.io/icon/bullhorn/ */ color: #2ecc40; } -#chat .mode .from:before { +#chat .mode .from::before { content: "\f05a"; /* http://fontawesome.io/icon/info-circle/ */ color: #2ecc40; } -#chat .ctcp .from:before { +#chat .ctcp .from::before { content: "\f0f6"; /* http://fontawesome.io/icon/file-text-o/ */ } -#chat .whois .from:before { +#chat .whois .from::before { content: "\f007"; /* http://fontawesome.io/icon/user/ */ color: #2ecc40; } -#chat .nick .from:before { +#chat .nick .from::before { content: "\f007"; /* http://fontawesome.io/icon/user/ */ color: #2ecc40; } -#chat .join .from:before { +#chat .join .from::before { content: "\f090"; /* http://fontawesome.io/icon/sign-in/ */ color: #2ecc40; } -#chat .kick .from:before { +#chat .kick .from::before { content: "\f05e"; /* http://fontawesome.io/icon/ban/ */ color: #ff4136; } -#chat .action .from:before { +#chat .action .from::before { content: "\f005"; /* http://fontawesome.io/icon/star/ */ } -#chat .count:before { +#chat .toggle-button { + /* These 2 directives are loosely taken from .fa-fw */ + width: 1.35em; + text-align: center; +} + +#chat .toggle-button::after { + content: "\f0da"; /* http://fontawesome.io/icon/caret-right/ */ +} + +#chat .count::before { color: #cfcfcf; content: "\f002"; /* http://fontawesome.io/icon/search/ */ position: absolute; @@ -281,23 +318,35 @@ kbd { line-height: 50px; } -#settings #play:before { +#settings .extra-help::before { + content: "\f059"; /* http://fontawesome.io/icon/question-circle/ */ +} + +#settings #play::before { content: "\f028"; /* http://fontawesome.io/icon/volume-up/ */ margin-right: 9px; } -#set-nick:before { +#set-nick::before { content: "\f040"; /* http://fontawesome.io/icon/pencil/ */ } -#submit-nick:before { +#submit-nick::before { content: "\f00c"; /* http://fontawesome.io/icon/check/ */ } -#cancel-nick:before { +#cancel-nick::before { content: "\f00d"; /* http://fontawesome.io/icon/times/ */ } +#image-viewer .previous-image-btn::before { + content: "\f104"; /* http://fontawesome.io/icon/angle-left/ */ +} + +#image-viewer .next-image-btn::before { + content: "\f105"; /* http://fontawesome.io/icon/angle-right/ */ +} + /* End icons */ #wrap { @@ -310,8 +359,6 @@ kbd { transition: transform 160ms, -webkit-transform 160ms; -webkit-transform: translateZ(0); transform: translateZ(0); - -webkit-perspective: 1000; - perspective: 1000; } #viewport.menu-open { @@ -327,6 +374,17 @@ kbd { pointer-events: none; } +#chat button, +#form button, +#chat .user { + transition: opacity 0.2s; +} + +#chat button:hover, +#form button:hover { + opacity: 0.6; +} + #viewport .lt, #viewport .rt, #chat button.menu { @@ -345,7 +403,7 @@ kbd { } /* Notification dot on the top right corner of the menu icon */ -#viewport .lt:after { +#viewport .lt::after { content: ""; position: absolute; top: 9px; @@ -356,10 +414,10 @@ kbd { border-radius: 50%; border: 2px solid white; opacity: 0; - transition: opacity .2s; + transition: opacity 0.2s; } -#viewport .lt.notified:after { +#viewport .lt.notified::after { opacity: 1; } @@ -379,8 +437,7 @@ kbd { } #viewport.rt #chat .sidebar { - -webkit-transform: translate3d(180px, 0, 0); - transform: translate3d(180px, 0, 0); + right: -180px; } #sidebar { @@ -394,6 +451,10 @@ kbd { width: 220px; } +#viewport.menu-open #sidebar { + will-change: transform; +} + #sidebar button, #sidebar .chan, #sidebar .sign-out { @@ -439,7 +500,7 @@ kbd { padding: 6px 10px 8px 36px; position: relative; text-align: left; - transition: color .2s; + transition: color 0.2s; width: 180px; left: auto !important; /* Fix for drag'n'drop not recalculating left position */ } @@ -459,21 +520,21 @@ kbd { color: #c0f8c3; } -#sidebar .chan:before, -#chat .title:before { +#sidebar .chan::before, +#chat .title::before { float: left; margin-top: 3px; margin-right: 12px; text-align: center; } -#sidebar .chan:before { +#sidebar .chan::before { position: absolute; top: 4px; left: 10px; } -#chat .title:before { +#chat .title::before { margin-top: 17px; } @@ -486,7 +547,7 @@ kbd { margin-right: 5px; } -#sidebar .chan .name:after { +#sidebar .chan .name::after { position: absolute; top: 0; right: 0; @@ -497,7 +558,7 @@ kbd { } #sidebar .badge { - background: rgba(255, 255, 255, .06); + background: rgba(255, 255, 255, 0.06); border-radius: 3px; color: #afb6c0; font-size: 10px; @@ -506,7 +567,7 @@ kbd { margin-left: 5px; padding: 3px 6px; float: right; - transition: opacity .2s, background-color .2s, color .2s; + transition: opacity 0.2s, background-color 0.2s, color 0.2s; } #sidebar .badge.highlight { @@ -527,10 +588,10 @@ kbd { position: absolute; z-index: 2; right: 0; - transition: opacity .2s, background-color .2s; + transition: opacity 0.2s, background-color 0.2s; } -#sidebar .close:before { +#sidebar .close::before { font-size: 18px; font-weight: normal; display: inline-block; @@ -544,11 +605,11 @@ kbd { #sidebar .chan.active .close { visibility: visible; - opacity: .4; + opacity: 0.4; } #sidebar .chan.active .close:hover { - background-color: rgba(0, 0, 0, .1); + background-color: rgba(0, 0, 0, 0.1); opacity: 1; } @@ -557,16 +618,8 @@ kbd { right: 3px; } -#sidebar, #footer { - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; -} - -#footer { - background: rgba(0, 0, 0, .06); + background: rgba(0, 0, 0, 0.06); bottom: 0; height: 45px; left: 0; @@ -586,6 +639,11 @@ kbd { display: inline-block; line-height: 34px; padding: 0 12px; + transition: color 0.2s; +} + +#footer .icon:hover { + color: #fff; } .signed-out #footer .sign-in { @@ -630,7 +688,8 @@ kbd { width: 100%; } -#windows label { +#windows label, +#settings .error { font-size: 14px; } @@ -644,7 +703,7 @@ kbd { margin-bottom: 10px; outline: 0; padding: 8px 10px; - transition: border-color .2s; + transition: border-color 0.2s; width: 100%; } @@ -704,7 +763,7 @@ kbd { } #windows .header .title { - font-size: 14px; + font-size: 15px; } #windows .header .topic { @@ -748,10 +807,44 @@ kbd { display: block; } +#chat .condensed { + flex-wrap: wrap; +} + +#chat .condensed .content { + flex: 1; +} + +/* Ensures expanded status messages always take up the full width */ +#chat .condensed .msg { + flex-basis: 100%; +} + +#chat .condensed-text { + cursor: pointer; + transition: opacity 0.2s; +} + +#chat .condensed-text:hover { + opacity: 0.6; +} + +#chat .condensed-text .toggle-button:hover { + opacity: 1; +} + +#chat .condensed.closed .msg { + display: none; +} + +#chat .condensed > .time { + visibility: hidden; +} + #windows .header .topic, .messages .msg, .sidebar { - font: 12px Consolas, Menlo, Monaco, "Lucida Console", "DejaVu Sans Mono", "Courier New", monospace; + font: 13px Consolas, Menlo, Monaco, "Lucida Console", "DejaVu Sans Mono", "Courier New", monospace; line-height: 1.4; } @@ -767,9 +860,14 @@ kbd { #chat .chat { bottom: 0; left: 0; + right: 0; overflow: auto; + will-change: transform, scroll-position; -webkit-overflow-scrolling: touch; position: absolute; +} + +#chat .channel .chat { right: 180px; } @@ -788,23 +886,7 @@ kbd { position: absolute; right: 0; width: 180px; - transition: all .4s; - -webkit-transform: translateZ(0); - transform: translateZ(0); - -webkit-perspective: 1000; - perspective: 1000; -} - -#chat .lobby .chat, -#chat .special .chat, -#chat .query .chat { - right: 0; -} - -#chat .lobby .sidebar, -#chat .special .sidebar, -#chat .query .sidebar { - display: none; + transition: right 0.4s; } #chat .show-more { @@ -832,41 +914,40 @@ kbd { } #chat .messages { - display: table; - table-layout: fixed; - width: 100%; padding: 10px 0; } #chat .msg { word-wrap: break-word; word-break: break-word; /* Webkit-specific */ + display: flex; + overflow: hidden; + position: relative; } #chat .unread-marker { position: relative; text-align: center; - opacity: .5; margin: 0 10px; z-index: 0; font-weight: bold; font-size: 12px; } -#chat .unread-marker:before { +#chat .unread-marker::before { position: absolute; z-index: -1; content: ""; left: 0; right: 0; top: 50%; - border-top: 1px solid #e74c3c; + border-top: 1px solid rgba(231, 76, 60, 0.5); } -#chat .unread-marker-text:before { +#chat .unread-marker-text::before { content: "New messages"; background-color: white; - color: #e74c3c; + color: rgba(231, 76, 60, 0.5); padding: 0 10px; } @@ -877,27 +958,26 @@ kbd { #chat .date-marker { position: relative; text-align: center; - opacity: .5; margin: 0 10px; z-index: 0; font-weight: bold; font-size: 12px; } -#chat .date-marker:before { +#chat .date-marker::before { position: absolute; z-index: -1; content: ""; left: 0; right: 0; top: 50%; - border-top: 1px solid #006b3b; + border-top: 1px solid rgba(0, 107, 59, 0.5); } -#chat .date-marker-text:before { - content: attr(data-date); +#chat .date-marker-text::before { + content: attr(data-label); background-color: white; - color: #006b3b; + color: rgba(0, 107, 59, 0.5); padding: 0 10px; } @@ -906,22 +986,20 @@ kbd { } .inline-channel:hover { - opacity: .6; + opacity: 0.6; } #chat .time, #chat .from, -#chat .text { - display: table-cell; +#chat .content { + display: block; padding: 2px 0; - vertical-align: top; + flex: 0 0 auto; } #chat .time { color: #ddd; - text-align: right; - max-width: 46px; - min-width: 46px; + padding-left: 10px; } #chat .from { @@ -929,8 +1007,15 @@ kbd { color: #b1c3ce; padding-right: 10px; text-align: right; - max-width: 134px; - min-width: 134px; + width: 134px; + overflow: hidden; + white-space: nowrap; + position: relative; +} + +#chat .content { + flex: 1 1 auto; + min-width: 0; } #loading a, @@ -951,7 +1036,7 @@ kbd { } #chat .user:hover { - opacity: .6; + opacity: 0.6; } #chat.colored-nicks .user.color-1 { color: #1396cf; } @@ -987,7 +1072,7 @@ kbd { #chat.colored-nicks .user.color-31 { color: #ff4846; } #chat.colored-nicks .user.color-32 { color: #ff199b; } -#chat .text { +#chat .content { padding-left: 10px; padding-right: 6px; } @@ -996,14 +1081,6 @@ kbd { color: #999; } -#chat .msg.motd .text, -#chat .msg.message .text, -#chat .msg.action .action-text, -#chat .msg.notice .text { - white-space: pre-wrap; - overflow: hidden; -} - #chat .msg.channel_list_loading .text { color: #999; font-style: italic; @@ -1015,25 +1092,36 @@ kbd { padding-left: 20px; } -#chat table.channel-list { +#chat table.channel-list, +#chat table.ban-list { margin: 5px 10px; width: calc(100% - 30px); } #chat table.channel-list th, -#chat table.channel-list td { +#chat table.ban-list th, +#chat table.channel-list td, +#chat table.ban-list td { padding: 5px; vertical-align: top; border-bottom: #eee 1px solid; } +#chat table.channel-list .channel { + width: 80px; +} + #chat table.channel-list .channel, -#chat table.channel-list .topic { +#chat table.channel-list .topic, +#chat table.ban-list .hostmask, +#chat table.ban-list .banned_by, +#chat table.ban-list .banned_at { text-align: left; } #chat table.channel-list .users { text-align: center; + width: 50px; } #chat table.channel-list td.channel .inline-channel { @@ -1044,23 +1132,25 @@ kbd { color: #555; } -#chat.hide-join .join, -#chat.hide-mode .mode, -#chat.hide-motd .motd, -#chat.hide-nick .nick, -#chat.hide-part .part, -#chat.hide-quit .quit { +#chat.hide-status-messages .join, +#chat.hide-status-messages .mode, +#chat.hide-status-messages .nick, +#chat.hide-status-messages .part, +#chat.hide-status-messages .quit, +#chat.hide-status-messages .condensed, +#chat.hide-motd .motd { display: none !important; } -#chat .join .text, -#chat .kick .text, -#chat .mode .text, -#chat .nick .text, -#chat .part .text, -#chat .quit .text, -#chat .topic .text, -#chat .topic_set_by .text { +#chat .condensed .content, +#chat .join .content, +#chat .kick .content, +#chat .mode .content, +#chat .nick .content, +#chat .part .content, +#chat .quit .content, +#chat .topic .content, +#chat .topic_set_by .content { color: #999; } @@ -1076,7 +1166,7 @@ kbd { color: #0074d9 !important; } -#chat .notice .user:before { +#chat .notice .user::before { content: "Notice: "; } @@ -1088,18 +1178,14 @@ kbd { color: #f00; } -#chat .msg.toggle .time { - visibility: hidden; +#chat .toggle-button { + display: inline-block; + transition: opacity 0.2s, transform 0.2s; } -#chat .toggle-button { - background: #f5f5f5; - border-radius: 2px; - display: inline-block; - color: #666; - height: 1em; - line-height: 0; - padding: 0 6px; +#chat .toggle-button.opened, /* Thumbnail toggle */ +#chat .msg.condensed:not(.closed) .toggle-button { /* Expanded status message toggle */ + transform: rotate(90deg); } #chat .toggle-content { @@ -1109,24 +1195,43 @@ kbd { color: #222; font-size: 12px; max-width: 100%; - padding: 6px 8px; - margin-top: 2px; -} - -#chat .toggle-content a { - color: inherit; + margin: 2px 0; + overflow: hidden; } #chat .toggle-content img { max-width: 100%; - max-height: 250px; + max-height: 128px; display: block; - margin: 2px 0; } #chat .toggle-content .thumb { - max-height: 110px; - max-width: 210px; + max-width: 48px; + max-height: 32px; +} + +#chat .toggle-thumbnail { + padding: 6px; +} + +#chat .toggle-text { + padding: 6px; + min-width: 0; + display: flex; + flex-direction: column; + white-space: nowrap; + color: inherit; +} + +#chat .toggle-text:not(:first-child) { + padding-left: 0; +} + +#chat .toggle-content .head, +#chat .toggle-content .body { + text-overflow: ellipsis; + overflow: hidden; + color: inherit; } #chat .toggle-content .head { @@ -1135,13 +1240,16 @@ kbd { #chat .toggle-content .body { color: #999; - max-width: 460px; - word-break: normal; - word-wrap: break-word; } #chat .toggle-content.show { - display: inline-block !important; + display: inline-flex !important; +} + +/* Do not display an empty div when there are no previews. Useful for example in +part/quit messages where we don't load previews (adds a blank line otherwise) */ +#chat .preview:empty { + display: none; } #chat .count { @@ -1168,6 +1276,7 @@ kbd { bottom: 0; overflow: auto; overflow-x: hidden; + will-change: transform, scroll-position; -webkit-overflow-scrolling: touch; padding-bottom: 10px; position: absolute; @@ -1175,13 +1284,17 @@ kbd { width: 100%; } +#chat .names-filtered { + display: none; +} + #chat .names .user { display: block; line-height: 1.6; padding: 0 16px; } -#chat .user-mode:before { +#chat .user-mode::before { content: ""; border-bottom: 1px solid #eee; display: block; @@ -1190,30 +1303,34 @@ kbd { margin-bottom: 10px; } -#chat .user-mode.owner:before { +#chat .user-mode.owner::before { content: "Owners"; } -#chat .user-mode.admin:before { +#chat .user-mode.admin::before { content: "Administrators"; } -#chat .user-mode.op:before { +#chat .user-mode.op::before { content: "Operators"; } -#chat .user-mode.half-op:before { +#chat .user-mode.half-op::before { content: "Half-Operators"; } -#chat .user-mode.voice:before { +#chat .user-mode.voice::before { content: "Voiced"; } -#chat .user-mode.normal:before { +#chat .user-mode.normal::before { content: "Users"; } +#chat .user-mode-search::before { + content: "Search Results"; +} + #loading { font-size: 14px; z-index: 1; @@ -1257,7 +1374,7 @@ kbd { margin-top: 11px; } -#connect .port:before { +#connect .port::before { content: ":"; margin: 9px 0 0 -17px; position: absolute; @@ -1281,25 +1398,33 @@ kbd { #settings .opt { display: block; - padding: 5px 0 10px 1px; + padding: 5px 0 5px 1px; } #settings .opt input { - float: left; - margin: 4px 10px 0 0; + margin-right: 6px; } +#settings .extra-help, #settings #play { color: #7f8c8d; } +#settings .extra-help { + cursor: help; +} + +#settings h2 .extra-help { + font-size: 0.8em; +} + #settings #play { font-size: 14px; - transition: opacity .2s; + transition: opacity 0.2s; } #settings #play:hover { - opacity: .8; + opacity: 0.8; } #settings #change-password .error, @@ -1317,7 +1442,7 @@ kbd { #settings .error { color: #e74c3c; - margin-top: .2em; + margin-top: 0.2em; } #help .help-item { @@ -1327,17 +1452,16 @@ kbd { #help .help-item .subject, #help .help-item .description { display: table-cell; + font-size: 14px; padding-bottom: 15px; } #help .help-item .subject { white-space: nowrap; - padding-right: 10px; - min-width: 150px; + padding-right: 15px; } #help .help-item .description p { - font-size: 14px; margin-bottom: 0; } @@ -1355,7 +1479,7 @@ kbd { } #windows #form .input { - font: 12px Consolas, Menlo, Monaco, "Lucida Console", "DejaVu Sans Mono", "Courier New", monospace; + font: 13px Consolas, Menlo, Monaco, "Lucida Console", "DejaVu Sans Mono", "Courier New", monospace; border: 1px solid #ddd; border-radius: 2px; margin: 0; @@ -1392,14 +1516,10 @@ kbd { padding-left: 9px; padding-right: 5px; border-radius: 2px; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; -webkit-flex: 0 0 auto; flex: 0 0 auto; border: 1px solid transparent; - transition: border-color .2s; + transition: border-color 0.2s; } #form #nick-value { @@ -1440,7 +1560,7 @@ kbd { min-height: 18px; /* Required when computing input height at char deletion */ height: 18px; max-height: 90px; - line-height: 1.5; + line-height: 1.4; outline: none; margin: 5px; padding: 0; @@ -1450,26 +1570,16 @@ kbd { align-self: center; } -#form #cycle-nicks, #form #submit { color: #9ca5b4; font-size: 14px; height: 32px; - transition: opacity .2s; - width: 24px; + transition: opacity 0.2s; + width: 32px; -webkit-flex: 0 0 auto; flex: 0 0 auto; } -#form #submit { - margin-right: 4px; -} - -#form #cycle-nicks:hover, -#form #submit:hover { - opacity: .6; -} - #context-menu-container { display: none; position: absolute; @@ -1481,7 +1591,8 @@ kbd { background: transparent; } -#context-menu { +#context-menu, +.textcomplete-menu { position: absolute; list-style: none; margin: 0; @@ -1489,52 +1600,83 @@ kbd { min-width: 160px; font-size: 14px; background-color: #fff; - box-shadow: 0 3px 12px rgba(0, 0, 0, .15); - border: 1px solid rgba(0, 0, 0, .15); + box-shadow: 0 3px 12px rgba(0, 0, 0, 0.15); + border: 1px solid rgba(0, 0, 0, 0.15); border-radius: 2px; } .context-menu-divider { height: 1px; margin: 6px 0; - background-color: rgba(0, 0, 0, .1); + background-color: rgba(0, 0, 0, 0.1); } -.context-menu-item { +.context-menu-item, +.textcomplete-item { cursor: pointer; display: block; padding: 4px 8px; color: #333; margin-top: 6px; margin-bottom: 6px; + line-height: 1.4; + transition: background-color 0.2s; } -.context-menu-item:hover { +.context-menu-item:hover, +.textcomplete-item:hover, +.textcomplete-menu .active { background-color: #f6f6f6; + transition: none; } -.context-menu-item:before { +.context-menu-item::before, +.textcomplete-item::before { width: 20px; display: inline-block; } +.textcomplete-item a { + color: #333; +} + +.textcomplete-item a:hover { + opacity: 1; +} + +.emoji { + display: inline-block; + font-size: 1.4em; + vertical-align: bottom; + line-height: 1; +} + +.textcomplete-item .emoji { + width: 32px; + text-align: center; +} + +.textcomplete-item .irc-bg { + display: block; +} + /** - * Tooltips + * Tooltips v0.5.3 * See http://primercss.io/tooltips/ */ + .tooltipped { position: relative; } -.tooltipped:after { +.tooltipped::after { position: absolute; z-index: 1000000; - display: inline-block; - visibility: hidden; - opacity: 0; + display: none; padding: 5px 8px; font: 12px Lato; line-height: 1.2; + -webkit-font-smoothing: subpixel-antialiased; color: #fff; text-align: center; text-decoration: none; @@ -1547,47 +1689,83 @@ kbd { content: attr(aria-label); background: #222; border-radius: 3px; - -webkit-font-smoothing: subpixel-antialiased; - transition: .2s; + opacity: 0; } -.tooltipped:before { +.tooltipped::before { position: absolute; z-index: 1000001; - display: inline-block; - visibility: hidden; - opacity: 0; + display: none; width: 0; height: 0; color: #fff; pointer-events: none; content: ""; border: 5px solid transparent; - transition: .2s; + opacity: 0; } -.tooltipped:hover:before, -.tooltipped:hover:after, -.tooltipped:active:before, -.tooltipped:active:after, -.tooltipped:focus:before, -.tooltipped:focus:after { - visibility: visible; - opacity: 1; +@-webkit-keyframes tooltip-appear { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes tooltip-appear { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +.tooltipped:hover::before, +.tooltipped:hover::after, +.tooltipped:active::before, +.tooltipped:active::after, +.tooltipped:focus::before, +.tooltipped:focus::after { + display: inline-block; text-decoration: none; + -webkit-animation-name: tooltip-appear; + animation-name: tooltip-appear; + -webkit-animation-duration: 0.1s; + animation-duration: 0.1s; + -webkit-animation-fill-mode: forwards; + animation-fill-mode: forwards; + -webkit-animation-timing-function: ease-in; + animation-timing-function: ease-in; + -webkit-animation-delay: 0.4s; + animation-delay: 0.4s; } -.tooltipped-s:after, -.tooltipped-se:after, -.tooltipped-sw:after { +.tooltipped-no-delay:hover::before, +.tooltipped-no-delay:hover::after, +.tooltipped-no-delay:active::before, +.tooltipped-no-delay:active::after, +.tooltipped-no-delay:focus::before, +.tooltipped-no-delay:focus::after { + -webkit-animation-delay: 0s; + animation-delay: 0s; +} + +.tooltipped-s::after, +.tooltipped-se::after, +.tooltipped-sw::after { top: 100%; right: 50%; margin-top: 5px; } -.tooltipped-s:before, -.tooltipped-se:before, -.tooltipped-sw:before { +.tooltipped-s::before, +.tooltipped-se::before, +.tooltipped-sw::before { top: auto; right: 50%; bottom: -5px; @@ -1595,27 +1773,27 @@ kbd { border-bottom-color: #222; } -.tooltipped-se:after { +.tooltipped-se::after { right: auto; left: 50%; margin-left: -15px; } -.tooltipped-sw:after { +.tooltipped-sw::after { margin-right: -15px; } -.tooltipped-n:after, -.tooltipped-ne:after, -.tooltipped-nw:after { +.tooltipped-n::after, +.tooltipped-ne::after, +.tooltipped-nw::after { right: 50%; bottom: 100%; margin-bottom: 5px; } -.tooltipped-n:before, -.tooltipped-ne:before, -.tooltipped-nw:before { +.tooltipped-n::before, +.tooltipped-ne::before, +.tooltipped-nw::before { top: -5px; right: 50%; bottom: auto; @@ -1623,23 +1801,23 @@ kbd { border-top-color: #222; } -.tooltipped-ne:after { +.tooltipped-ne::after { right: auto; left: 50%; margin-left: -15px; } -.tooltipped-nw:after { +.tooltipped-nw::after { margin-right: -15px; } -.tooltipped-s:after, -.tooltipped-n:after { +.tooltipped-s::after, +.tooltipped-n::after { -webkit-transform: translateX(50%); transform: translateX(50%); } -.tooltipped-w:after { +.tooltipped-w::after { right: 100%; bottom: 50%; margin-right: 5px; @@ -1647,7 +1825,7 @@ kbd { transform: translateY(50%); } -.tooltipped-w:before { +.tooltipped-w::before { top: 50%; bottom: 50%; left: -5px; @@ -1655,7 +1833,7 @@ kbd { border-left-color: #222; } -.tooltipped-e:after { +.tooltipped-e::after { bottom: 50%; left: 100%; margin-left: 5px; @@ -1663,7 +1841,7 @@ kbd { transform: translateY(50%); } -.tooltipped-e:before { +.tooltipped-e::before { top: 50%; right: -5px; bottom: 50%; @@ -1671,6 +1849,14 @@ kbd { border-right-color: #222; } +@media + only screen and (min-resolution: 192dpi), + only screen and (min-resolution: 2dppx) { + .tooltipped-w::after { + margin-right: 4.5px; + } +} + /* End tooltips */ /** @@ -1754,6 +1940,26 @@ kbd { font-style: italic; } +@media (min-width: 480px) { + /* Fade out for long usernames */ + + #chat .from { + padding-left: 10px; + } + + #chat .from::after { + position: absolute; + right: 0; + bottom: 0; + width: 10px; + height: 100%; + background: linear-gradient(to right, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 1) 100%); + content: " "; + } + + /* End fade out for long usernames */ +} + @media (max-width: 768px) { /** * TODO Replace this with `@media (hover: hover)` when Firefox supports it @@ -1763,12 +1969,8 @@ kbd { * - https://www.w3.org/TR/mediaqueries-4/ * - https://developer.mozilla.org/en-US/docs/Web/CSS/@media/hover */ - .tooltipped:hover:before, - .tooltipped:hover:after, - .tooltipped:active:before, - .tooltipped:active:after, - .tooltipped:focus:before, - .tooltipped:focus:after { + .tooltipped-no-touch:hover::before, + .tooltipped-no-touch:hover::after { visibility: hidden; opacity: 0; } @@ -1777,17 +1979,12 @@ kbd { margin-top: 60px !important; } - #viewport.rt #chat .sidebar { - -webkit-transform: translate3d(-180px, 0, 0); - transform: translate3d(-180px, 0, 0); - } - #sidebar, #footer { left: -220px; } - #sidebar .empty:before { + #sidebar .empty::before { margin-top: 0; } @@ -1808,23 +2005,28 @@ kbd { display: block; } + #chat .channel .chat { + right: 0; + } + #chat .sidebar { right: -180px; } - #chat .title:before { + #viewport.rt #chat .sidebar { + right: 0; + } + + #chat .title::before { display: none; } } -@media (min-width: 769px) { - #viewport { - -webkit-transform: none !important; - transform: none !important; - } - - #viewport.menu-open { - transition: none; +@media (min-width: 1610px) { + #windows .header .topic, + .messages .msg, + .sidebar { + font-size: 14px; } } @@ -1853,12 +2055,17 @@ kbd { #chat .time, #chat .from, - #chat .text { + #chat .content { border: 0; display: inline; padding: 0; } + #chat .condensed .time, + #chat .condensed .from { + display: none; + } + #chat .date-marker, #chat .unread-marker { margin: 0; @@ -1880,14 +2087,120 @@ kbd { } ::-webkit-scrollbar:hover { - background-color: rgba(0, 0, 0, .09); + background-color: rgba(0, 0, 0, 0.09); } ::-webkit-scrollbar-thumb:vertical { - background: rgba(0, 0, 0, .5); + background: rgba(0, 0, 0, 0.5); border-radius: 100px; } ::-webkit-scrollbar-thumb:vertical:active { - background: rgba(0, 0, 0, .6); + background: rgba(0, 0, 0, 0.6); +} + +/* Image viewer */ + +#image-viewer, +#image-viewer .close-btn { + /* Vertically and horizontally center stuff */ + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +#image-viewer { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + background: black; + visibility: hidden; + opacity: 0; + transition: opacity 0.2s, visibility 0.2s; + z-index: 999; +} + +#image-viewer.opened { + visibility: visible; + opacity: 1; +} + +#image-viewer .close-btn, +#image-viewer .previous-image-btn, +#image-viewer .next-image-btn { + position: fixed; + top: 0; + width: 2em; + font-size: 36px; + color: white; + opacity: 0.6; + transition: 0.2s opacity; +} + +#image-viewer .close-btn { + right: 0; + height: 2em; + z-index: 1002; +} + +#image-viewer .close-btn::before { + content: "×"; +} + +#image-viewer .previous-image-btn, +#image-viewer .next-image-btn { + bottom: 0; + z-index: 1001; +} + +#image-viewer .previous-image-btn { + left: 0; +} + +#image-viewer .next-image-btn { + right: 0; +} + +#image-viewer .close-btn:hover, +#image-viewer .previous-image-btn:hover, +#image-viewer .next-image-btn:hover { + opacity: 1; +} + +#image-viewer .image-link { + margin: 10px; +} + +#image-viewer .image-link:hover { + opacity: 1; +} + +#image-viewer .image-link img { + max-width: 100%; + + /* Top/Bottom margins + button height + image/button margin */ + max-height: calc(100vh - 2 * 10px - 37px - 10px); +} + +#image-viewer .open-btn { + margin: 0 auto 10px; +} + +/* Correctly handle multiple successive whitespace characters. + For example: user has quit ( ===> L O L <=== ) */ + +#windows .header .topic, +#chat .message .text, +#chat .motd .text, +#chat .notice .text, +#chat .ctcp-message, +#chat .part-reason, +#chat .quit-reason, +#chat .new-topic, +#chat .action .text, +#chat table.channel-list .topic { + white-space: pre-wrap; } diff --git a/client/index.html b/client/index.html index 0ec349a6..d3dac9d5 100644 --- a/client/index.html +++ b/client/index.html @@ -4,26 +4,28 @@ <meta charset="utf-8"> <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"> - <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> - <meta name="mobile-web-app-capable" content="yes"> - <meta name="referrer" content="no-referrer"> - <meta name="theme-color" content="#455164"> + + <link rel="preload" as="script" href="js/bundle.vendor.js"> + <link rel="preload" as="script" href="js/bundle.js"> + <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> <title>The Lounge - - - - - + + + + + + - "> +
@@ -34,11 +36,11 @@
@@ -49,8 +51,11 @@

The Lounge is loading…

-

Loading the app… Make sure to have JavaScript enabled.

-

This is taking longer than it should, there might be connectivity issues.

+

Loading the app… Make sure to have JavaScript enabled.

+
+

This is taking longer than it should, there might be connectivity issues.

+ +
@@ -68,10 +73,7 @@ --> - - - - +
@@ -97,7 +99,7 @@
@@ -120,12 +122,17 @@

- <%= public ? "The Lounge - " : "" %> + {{#if public}}The Lounge - {{/if}} Connect - <%= !displayNetwork && lockNetwork ? "to " + defaults.name : "" %> + {{#unless displayNetwork}} + {{#if lockNetwork}} + to {{defaults.name}} + {{/if}} + {{/unless}}

-
> + {{#if displayNetwork}} +

Network settings

@@ -133,17 +140,17 @@
- +
- > +
- > +
@@ -151,16 +158,17 @@
- +
+ {{/if}}

User preferences

@@ -168,27 +176,27 @@
- +
- <% if (!useHexIp) { %> + {{#unless useHexIp}}
- +
- <% } %> + {{/unless}}
- +
- +
@@ -208,12 +216,6 @@

Messages

-
- -
-
- +
+

+ Status messages + + + +

-
+
-
-
+
@@ -252,6 +258,10 @@ Enable colored nicknames +

Theme

@@ -259,44 +269,60 @@
- <% if (typeof prefetch === "undefined" || prefetch !== false) { %> + {{#if prefetch}}
-

Links and URLs

+

Link previews

- <% } %> + {{/if}} + {{#unless public}}
-

Notifications

+

Push Notifications

+
+
+ +
+ Warning: + Push notifications are only supported over HTTPS connections. +
+
+ Warning: + Push notifications are not supported by your browser. +
+
+ {{/unless}} +
+

Browser Notifications

@@ -326,7 +352,8 @@
- <% if (!public && !ldap.enable) { %> + {{#unless public}} + {{#unless ldap.enable}}
@@ -350,7 +377,8 @@
- <% } %> + {{/unless}} + {{/unless}}

Custom Stylesheet

@@ -390,6 +418,62 @@
+
+
+ Ctrl + K +
+
+

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

+

+ A color reference can be found + here. +

+
+
+ +
+
+ Ctrl + B +
+
+

Mark all text typed after this shortcut as bold.

+
+
+ +
+
+ Ctrl + U +
+
+

Mark all text typed after this shortcut as underlined.

+
+
+ +
+
+ Ctrl + I +
+
+

Mark all text typed after this shortcut as italics.

+
+
+ +
+
+ Ctrl + O +
+
+

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

+
+
+

On macOS

@@ -403,13 +487,69 @@
- + K + + + L

Clear the current screen

+
+
+ + K +
+
+

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

+

+ A color reference can be found + here. +

+
+
+ +
+
+ + B +
+
+

Mark all text typed after this shortcut as bold.

+
+
+ +
+
+ + U +
+
+

Mark all text typed after this shortcut as underlined.

+
+
+ +
+
+ + I +
+
+

Mark all text typed after this shortcut as italics.

+
+
+ +
+
+ + O +
+
+

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

+
+
+

Commands

@@ -430,6 +570,25 @@
+
+
+ /ban nick +
+
+

Ban (+b) a user from the current channel. + This can be a nickname or a hostmask.

+
+
+ +
+
+ /banlist +
+
+

Load the banlist for the current channel.

+
+
+
/clear @@ -439,6 +598,18 @@
+
+
+ /collapse +
+
+

+ Collapse all previews in the current channel (opposite of + /expand) +

+
+
+
/connect host [port] @@ -461,7 +632,7 @@

Send a CTCP request. Read more about this on - the dedicated Wikipedia article. + the dedicated Wikipedia article.

@@ -502,6 +673,18 @@
+
+
+ /expand +
+
+

+ Expand all previews in the current channel (opposite of + /collapse) +

+
+
+
/invite nick [channel] @@ -666,6 +849,16 @@
+
+
+ /unban nick +
+
+

Unban (-b) a user from the current channel. + This can be a nickname or a hostmask.

+
+
+
/voice nick [...nick] @@ -693,17 +886,17 @@

About The Lounge

- <% if (gitCommit) { %> + {{#if gitCommit}} The Lounge is running from source - (<%= gitCommit %>).
- <% } else { %> - The Lounge is in version <%= version %> - (See release notes).
- <% } %> + ({{ gitCommit }}).
+ {{else}} + The Lounge is in version {{version}} + (See release notes).
+ {{/if}} - Website
- Documentation
- Report a bug + Website
+ Documentation
+ Report a bug

@@ -716,6 +909,8 @@ +
+ diff --git a/client/js/autocompletion.js b/client/js/autocompletion.js new file mode 100644 index 00000000..cdd39ec6 --- /dev/null +++ b/client/js/autocompletion.js @@ -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 `${emojiMap[original]} ${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: "", + post: "" + }).rendered]; + } + return i; + }); + + callback(matchingColorCodes); + }, + template(value) { + return `${value[1]}`; + }, + 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: "", + post: "" + }).rendered]; + } + return pair; + }) + .map((pair) => pair.concat(match[1])); // Needed to pass fg color to `template`... + + callback(matchingColorCodes); + }, + template(value) { + return `${value[1]}`; + }, + 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: "", + post: "" + } + ); + 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); +} diff --git a/client/js/condensed.js b/client/js/condensed.js new file mode 100644 index 00000000..a7119d1f --- /dev/null +++ b/client/js/condensed.js @@ -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()); +} diff --git a/client/js/constants.js b/client/js/constants.js new file mode 100644 index 00000000..f34c24a3 --- /dev/null +++ b/client/js/constants.js @@ -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 +}; diff --git a/client/js/libs/handlebars/colorClass.js b/client/js/libs/handlebars/colorClass.js index 53bfc0d4..e7b8d8e4 100644 --- a/client/js/libs/handlebars/colorClass.js +++ b/client/js/libs/handlebars/colorClass.js @@ -1,3 +1,5 @@ +"use strict"; + // Generates a string from "color-1" to "color-32" based on an input string module.exports = function(str) { var hash = 0; diff --git a/client/js/libs/handlebars/diff.js b/client/js/libs/handlebars/diff.js index 3b2116bd..198c8882 100644 --- a/client/js/libs/handlebars/diff.js +++ b/client/js/libs/handlebars/diff.js @@ -1,3 +1,5 @@ +"use strict"; + var diff; module.exports = function(a, opt) { diff --git a/client/js/libs/handlebars/equal.js b/client/js/libs/handlebars/equal.js index 1d7a4393..6a8033ca 100644 --- a/client/js/libs/handlebars/equal.js +++ b/client/js/libs/handlebars/equal.js @@ -1,3 +1,5 @@ +"use strict"; + module.exports = function(a, b, opt) { a = a.toString(); b = b.toString(); diff --git a/client/js/libs/handlebars/friendlydate.js b/client/js/libs/handlebars/friendlydate.js new file mode 100644 index 00000000..60d8f402 --- /dev/null +++ b/client/js/libs/handlebars/friendlydate.js @@ -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" + }); +}; diff --git a/client/js/libs/handlebars/ircmessageparser/anyIntersection.js b/client/js/libs/handlebars/ircmessageparser/anyIntersection.js new file mode 100644 index 00000000..a77e031d --- /dev/null +++ b/client/js/libs/handlebars/ircmessageparser/anyIntersection.js @@ -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; diff --git a/client/js/libs/handlebars/ircmessageparser/fill.js b/client/js/libs/handlebars/ircmessageparser/fill.js new file mode 100644 index 00000000..7d90a96c --- /dev/null +++ b/client/js/libs/handlebars/ircmessageparser/fill.js @@ -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; diff --git a/client/js/libs/handlebars/ircmessageparser/findChannels.js b/client/js/libs/handlebars/ircmessageparser/findChannels.js new file mode 100644 index 00000000..6edd5dad --- /dev/null +++ b/client/js/libs/handlebars/ircmessageparser/findChannels.js @@ -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; diff --git a/client/js/libs/handlebars/ircmessageparser/findEmoji.js b/client/js/libs/handlebars/ircmessageparser/findEmoji.js new file mode 100644 index 00000000..15d4a9cb --- /dev/null +++ b/client/js/libs/handlebars/ircmessageparser/findEmoji.js @@ -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; diff --git a/client/js/libs/handlebars/ircmessageparser/findLinks.js b/client/js/libs/handlebars/ircmessageparser/findLinks.js new file mode 100644 index 00000000..50c442e6 --- /dev/null +++ b/client/js/libs/handlebars/ircmessageparser/findLinks.js @@ -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 + // -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; diff --git a/client/js/libs/handlebars/ircmessageparser/merge.js b/client/js/libs/handlebars/ircmessageparser/merge.js new file mode 100644 index 00000000..623327d8 --- /dev/null +++ b/client/js/libs/handlebars/ircmessageparser/merge.js @@ -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; diff --git a/client/js/libs/handlebars/ircmessageparser/parseStyle.js b/client/js/libs/handlebars/ircmessageparser/parseStyle.js new file mode 100644 index 00000000..2d4ca24d --- /dev/null +++ b/client/js/libs/handlebars/ircmessageparser/parseStyle.js @@ -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; diff --git a/client/js/libs/handlebars/localedate.js b/client/js/libs/handlebars/localedate.js index 0f85a5f2..3e6f7285 100644 --- a/client/js/libs/handlebars/localedate.js +++ b/client/js/libs/handlebars/localedate.js @@ -1,3 +1,7 @@ +"use strict"; + +const moment = require("moment"); + module.exports = function(time) { - return new Date(time).toLocaleDateString(); + return moment(time).format("D MMMM YYYY"); }; diff --git a/client/js/libs/handlebars/localetime.js b/client/js/libs/handlebars/localetime.js index 5318a022..17a6043d 100644 --- a/client/js/libs/handlebars/localetime.js +++ b/client/js/libs/handlebars/localetime.js @@ -1,3 +1,7 @@ +"use strict"; + +const moment = require("moment"); + module.exports = function(time) { - return new Date(time).toLocaleString(); + return moment(time).format("D MMMM YYYY, HH:mm:ss"); }; diff --git a/client/js/libs/handlebars/modes.js b/client/js/libs/handlebars/modes.js index 6416515f..ff3d555e 100644 --- a/client/js/libs/handlebars/modes.js +++ b/client/js/libs/handlebars/modes.js @@ -1,3 +1,5 @@ +"use strict"; + module.exports = function(mode) { var modes = { "~": "owner", diff --git a/client/js/libs/handlebars/parse.js b/client/js/libs/handlebars/parse.js index cd61ff7e..fdf32bdc 100644 --- a/client/js/libs/handlebars/parse.js +++ b/client/js/libs/handlebars/parse.js @@ -1,125 +1,90 @@ -import Handlebars from "handlebars/runtime"; -import URI from "urijs"; +"use strict"; -module.exports = function(text) { - text = Handlebars.Utils.escapeExpression(text); - text = colors(text); - text = channels(text); - text = uri(text); - return text; +const Handlebars = require("handlebars/runtime"); +const parseStyle = require("./ircmessageparser/parseStyle"); +const findChannels = require("./ircmessageparser/findChannels"); +const findLinks = require("./ircmessageparser/findLinks"); +const findEmoji = require("./ircmessageparser/findEmoji"); +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 `${escapedText}`; + } + + 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 `${fragments}`; + } else if (textPart.channel) { + const escapedChannel = Handlebars.Utils.escapeExpression(textPart.channel); + return `${fragments}`; + } else if (textPart.emoji) { + return `${fragments}`; + } + + return fragments; + }).join(""); }; - -function uri(text) { - return URI.withinString(text, function(url) { - if (url.indexOf("javascript:") === 0) { - return url; - } - var split = url.split("<"); - url = "" + split[0] + ""; - 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 '&' 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|,)((?:#|&)[^\x07\s,]{1,49})/g, - '$1$2' - ); -} - -/** - * 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 "" + settings.text + ""; -} - -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; -} diff --git a/client/js/libs/handlebars/roundBadgeNumber.js b/client/js/libs/handlebars/roundBadgeNumber.js index a7750de0..97c50afa 100644 --- a/client/js/libs/handlebars/roundBadgeNumber.js +++ b/client/js/libs/handlebars/roundBadgeNumber.js @@ -1,3 +1,5 @@ +"use strict"; + module.exports = function(count) { if (count < 1000) { return count; diff --git a/client/js/libs/handlebars/slugify.js b/client/js/libs/handlebars/slugify.js new file mode 100644 index 00000000..a8b385e8 --- /dev/null +++ b/client/js/libs/handlebars/slugify.js @@ -0,0 +1,5 @@ +"use strict"; + +module.exports = function(orig) { + return orig.toLowerCase().replace(/[^a-z0-9]/, "-"); +}; diff --git a/client/js/libs/handlebars/tojson.js b/client/js/libs/handlebars/tojson.js index fcd0fde1..418ac8c4 100644 --- a/client/js/libs/handlebars/tojson.js +++ b/client/js/libs/handlebars/tojson.js @@ -1,3 +1,5 @@ +"use strict"; + module.exports = function(context) { return window.JSON.stringify(context); }; diff --git a/client/js/libs/handlebars/tz.js b/client/js/libs/handlebars/tz.js index 14f05e5e..2e807d52 100644 --- a/client/js/libs/handlebars/tz.js +++ b/client/js/libs/handlebars/tz.js @@ -1,15 +1,10 @@ +"use strict"; + +const moment = require("moment"); +const constants = require("../../constants"); + module.exports = function(time) { - time = new Date(time); - var h = time.getHours(); - var m = time.getMinutes(); - - if (h < 10) { - h = "0" + h; - } - - if (m < 10) { - m = "0" + m; - } - - return h + ":" + m; + const options = require("../../options"); + const format = options.showSeconds ? constants.timeFormats.msgWithSeconds : constants.timeFormats.msgDefault; + return moment(time).format(format); }; diff --git a/client/js/libs/handlebars/users.js b/client/js/libs/handlebars/users.js deleted file mode 100644 index e52e7526..00000000 --- a/client/js/libs/handlebars/users.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = function(count) { - return count + " " + (count === 1 ? "user" : "users"); -}; diff --git a/client/js/libs/jquery/inputhistory.js b/client/js/libs/jquery/inputhistory.js index 1f5b7bf8..d6f98360 100644 --- a/client/js/libs/jquery/inputhistory.js +++ b/client/js/libs/jquery/inputhistory.js @@ -34,7 +34,7 @@ import jQuery from "jquery"; var key = e.which; switch (key) { case 13: // Enter - if (e.shiftKey) { + if (e.shiftKey || self.data("autocompleting")) { return; // multiline input } @@ -56,7 +56,7 @@ import jQuery from "jquery"; case 38: // Up case 40: // Down // NOTICE: This is specific to The Lounge. - if (e.ctrlKey || e.metaKey) { + if (e.ctrlKey || e.metaKey || self.data("autocompleting")) { break; } diff --git a/client/js/libs/jquery/stickyscroll.js b/client/js/libs/jquery/stickyscroll.js index c3500be5..fa83f688 100644 --- a/client/js/libs/jquery/stickyscroll.js +++ b/client/js/libs/jquery/stickyscroll.js @@ -37,7 +37,7 @@ import jQuery from "jquery"; lastStick = Date.now(); this.scrollTop = this.scrollHeight; }) - .on("msg.sticky", keepToBottom) + .on("keepToBottom.sticky", keepToBottom) .scrollBottom(); return self; diff --git a/client/js/libs/simplemap.json b/client/js/libs/simplemap.json new file mode 100644 index 00000000..2f3f0567 --- /dev/null +++ b/client/js/libs/simplemap.json @@ -0,0 +1,1434 @@ +{ + "100": "💯", + "1234": "🔢", + "grinning": "😀", + "grimacing": "😬", + "grin": "😁", + "joy": "😂", + "rofl": "🤣", + "smiley": "😃", + "smile": "😄", + "sweat_smile": "😅", + "laughing": "😆", + "innocent": "😇", + "wink": "😉", + "blush": "😊", + "slightly_smiling_face": "🙂", + "upside_down_face": "🙃", + "relaxed": "☺️", + "yum": "😋", + "relieved": "😌", + "heart_eyes": "😍", + "kissing_heart": "😘", + "kissing": "😗", + "kissing_smiling_eyes": "😙", + "kissing_closed_eyes": "😚", + "stuck_out_tongue_winking_eye": "😜", + "stuck_out_tongue_closed_eyes": "😝", + "stuck_out_tongue": "😛", + "money_mouth_face": "🤑", + "nerd_face": "🤓", + "sunglasses": "😎", + "clown_face": "🤡", + "cowboy_hat_face": "🤠", + "hugs": "🤗", + "smirk": "😏", + "no_mouth": "😶", + "neutral_face": "😐", + "expressionless": "😑", + "unamused": "😒", + "roll_eyes": "🙄", + "thinking": "🤔", + "lying_face": "🤥", + "flushed": "😳", + "disappointed": "😞", + "worried": "😟", + "angry": "😠", + "rage": "😡", + "pensive": "😔", + "confused": "😕", + "slightly_frowning_face": "🙁", + "frowning_face": "☹", + "persevere": "😣", + "confounded": "😖", + "tired_face": "😫", + "weary": "😩", + "triumph": "😤", + "open_mouth": "😮", + "scream": "😱", + "fearful": "😨", + "cold_sweat": "😰", + "hushed": "😯", + "frowning": "😦", + "anguished": "😧", + "cry": "😢", + "disappointed_relieved": "😥", + "drooling_face": "🤤", + "sleepy": "😪", + "sweat": "😓", + "sob": "😭", + "dizzy_face": "😵", + "astonished": "😲", + "zipper_mouth_face": "🤐", + "nauseated_face": "🤢", + "sneezing_face": "🤧", + "mask": "😷", + "face_with_thermometer": "🤒", + "face_with_head_bandage": "🤕", + "sleeping": "😴", + "zzz": "💤", + "poop": "💩", + "smiling_imp": "😈", + "imp": "👿", + "japanese_ogre": "👹", + "japanese_goblin": "👺", + "skull": "💀", + "ghost": "👻", + "alien": "👽", + "robot": "🤖", + "smiley_cat": "😺", + "smile_cat": "😸", + "joy_cat": "😹", + "heart_eyes_cat": "😻", + "smirk_cat": "😼", + "kissing_cat": "😽", + "scream_cat": "🙀", + "crying_cat_face": "😿", + "pouting_cat": "😾", + "raised_hands": "🙌", + "clap": "👏", + "wave": "👋", + "call_me_hand": "🤙", + "+1": "👍", + "-1": "👎", + "facepunch": "👊", + "fist": "✊", + "fist_left": "🤛", + "fist_right": "🤜", + "v": "✌", + "ok_hand": "👌", + "raised_hand": "✋", + "raised_back_of_hand": "🤚", + "open_hands": "👐", + "muscle": "💪", + "pray": "🙏", + "handshake": "🤝", + "point_up": "☝", + "point_up_2": "👆", + "point_down": "👇", + "point_left": "👈", + "point_right": "👉", + "fu": "🖕", + "raised_hand_with_fingers_splayed": "🖐", + "metal": "🤘", + "crossed_fingers": "🤞", + "vulcan_salute": "🖖", + "writing_hand": "✍", + "selfie": "🤳", + "nail_care": "💅", + "lips": "👄", + "tongue": "👅", + "ear": "👂", + "nose": "👃", + "eye": "👁", + "eyes": "👀", + "bust_in_silhouette": "👤", + "busts_in_silhouette": "👥", + "speaking_head": "🗣", + "baby": "👶", + "boy": "👦", + "girl": "👧", + "man": "👨", + "woman": "👩", + "blonde_woman": "👱‍♀️", + "blonde_man": "👱", + "older_man": "👴", + "older_woman": "👵", + "man_with_gua_pi_mao": "👲", + "woman_with_turban": "👳‍♀️", + "man_with_turban": "👳", + "policewoman": "👮‍♀️", + "policeman": "👮", + "construction_worker_woman": "👷‍♀️", + "construction_worker_man": "👷", + "guardswoman": "💂‍♀️", + "guardsman": "💂", + "female_detective": "🕵️‍♀️", + "male_detective": "🕵", + "woman_health_worker": "👩‍⚕️", + "man_health_worker": "👨‍⚕️", + "woman_farmer": "👩‍🌾", + "man_farmer": "👨‍🌾", + "woman_cook": "👩‍🍳", + "man_cook": "👨‍🍳", + "woman_student": "👩‍🎓", + "man_student": "👨‍🎓", + "woman_singer": "👩‍🎤", + "man_singer": "👨‍🎤", + "woman_teacher": "👩‍🏫", + "man_teacher": "👨‍🏫", + "woman_factory_worker": "👩‍🏭", + "man_factory_worker": "👨‍🏭", + "woman_technologist": "👩‍💻", + "man_technologist": "👨‍💻", + "woman_office_worker": "👩‍💼", + "man_office_worker": "👨‍💼", + "woman_mechanic": "👩‍🔧", + "man_mechanic": "👨‍🔧", + "woman_scientist": "👩‍🔬", + "man_scientist": "👨‍🔬", + "woman_artist": "👩‍🎨", + "man_artist": "👨‍🎨", + "woman_firefighter": "👩‍🚒", + "man_firefighter": "👨‍🚒", + "woman_pilot": "👩‍✈️", + "man_pilot": "👨‍✈️", + "woman_astronaut": "👩‍🚀", + "man_astronaut": "👨‍🚀", + "woman_judge": "👩‍⚖️", + "man_judge": "👨‍⚖️", + "mrs_claus": "🤶", + "santa": "🎅", + "angel": "👼", + "pregnant_woman": "🤰", + "princess": "👸", + "prince": "🤴", + "bride_with_veil": "👰", + "man_in_tuxedo": "🤵", + "running_woman": "🏃‍♀️", + "running_man": "🏃", + "walking_woman": "🚶‍♀️", + "walking_man": "🚶", + "dancer": "💃", + "man_dancing": "🕺", + "dancing_women": "👯", + "dancing_men": "👯‍♂️", + "couple": "👫", + "two_men_holding_hands": "👬", + "two_women_holding_hands": "👭", + "bowing_woman": "🙇‍♀️", + "bowing_man": "🙇", + "man_facepalming": "🤦", + "woman_facepalming": "🤦‍♀️", + "woman_shrugging": "🤷", + "man_shrugging": "🤷‍♂️", + "tipping_hand_woman": "💁", + "tipping_hand_man": "💁‍♂️", + "no_good_woman": "🙅", + "no_good_man": "🙅‍♂️", + "ok_woman": "🙆", + "ok_man": "🙆‍♂️", + "raising_hand_woman": "🙋", + "raising_hand_man": "🙋‍♂️", + "pouting_woman": "🙎", + "pouting_man": "🙎‍♂️", + "frowning_woman": "🙍", + "frowning_man": "🙍‍♂️", + "haircut_woman": "💇", + "haircut_man": "💇‍♂️", + "massage_woman": "💆", + "massage_man": "💆‍♂️", + "couple_with_heart_woman_man": "💑", + "couple_with_heart_woman_woman": "👩‍❤️‍👩", + "couple_with_heart_man_man": "👨‍❤️‍👨", + "couplekiss_man_woman": "💏", + "couplekiss_woman_woman": "👩‍❤️‍💋‍👩", + "couplekiss_man_man": "👨‍❤️‍💋‍👨", + "family_man_woman_boy": "👪", + "family_man_woman_girl": "👨‍👩‍👧", + "family_man_woman_girl_boy": "👨‍👩‍👧‍👦", + "family_man_woman_boy_boy": "👨‍👩‍👦‍👦", + "family_man_woman_girl_girl": "👨‍👩‍👧‍👧", + "family_woman_woman_boy": "👩‍👩‍👦", + "family_woman_woman_girl": "👩‍👩‍👧", + "family_woman_woman_girl_boy": "👩‍👩‍👧‍👦", + "family_woman_woman_boy_boy": "👩‍👩‍👦‍👦", + "family_woman_woman_girl_girl": "👩‍👩‍👧‍👧", + "family_man_man_boy": "👨‍👨‍👦", + "family_man_man_girl": "👨‍👨‍👧", + "family_man_man_girl_boy": "👨‍👨‍👧‍👦", + "family_man_man_boy_boy": "👨‍👨‍👦‍👦", + "family_man_man_girl_girl": "👨‍👨‍👧‍👧", + "family_woman_boy": "👩‍👦", + "family_woman_girl": "👩‍👧", + "family_woman_girl_boy": "👩‍👧‍👦", + "family_woman_boy_boy": "👩‍👦‍👦", + "family_woman_girl_girl": "👩‍👧‍👧", + "family_man_boy": "👨‍👦", + "family_man_girl": "👨‍👧", + "family_man_girl_boy": "👨‍👧‍👦", + "family_man_boy_boy": "👨‍👦‍👦", + "family_man_girl_girl": "👨‍👧‍👧", + "womans_clothes": "👚", + "tshirt": "👕", + "jeans": "👖", + "necktie": "👔", + "dress": "👗", + "bikini": "👙", + "kimono": "👘", + "lipstick": "💄", + "kiss": "💋", + "footprints": "👣", + "high_heel": "👠", + "sandal": "👡", + "boot": "👢", + "mans_shoe": "👞", + "athletic_shoe": "👟", + "womans_hat": "👒", + "tophat": "🎩", + "rescue_worker_helmet": "⛑", + "mortar_board": "🎓", + "crown": "👑", + "school_satchel": "🎒", + "pouch": "👝", + "purse": "👛", + "handbag": "👜", + "briefcase": "💼", + "eyeglasses": "👓", + "dark_sunglasses": "🕶", + "ring": "💍", + "closed_umbrella": "🌂", + "dog": "🐶", + "cat": "🐱", + "mouse": "🐭", + "hamster": "🐹", + "rabbit": "🐰", + "fox_face": "🦊", + "bear": "🐻", + "panda_face": "🐼", + "koala": "🐨", + "tiger": "🐯", + "lion": "🦁", + "cow": "🐮", + "pig": "🐷", + "pig_nose": "🐽", + "frog": "🐸", + "squid": "🦑", + "octopus": "🐙", + "shrimp": "🦐", + "monkey_face": "🐵", + "gorilla": "🦍", + "see_no_evil": "🙈", + "hear_no_evil": "🙉", + "speak_no_evil": "🙊", + "monkey": "🐒", + "chicken": "🐔", + "penguin": "🐧", + "bird": "🐦", + "baby_chick": "🐤", + "hatching_chick": "🐣", + "hatched_chick": "🐥", + "duck": "🦆", + "eagle": "🦅", + "owl": "🦉", + "bat": "🦇", + "wolf": "🐺", + "boar": "🐗", + "horse": "🐴", + "unicorn": "🦄", + "honeybee": "🐝", + "bug": "🐛", + "butterfly": "🦋", + "snail": "🐌", + "beetle": "🐞", + "ant": "🐜", + "spider": "🕷", + "scorpion": "🦂", + "crab": "🦀", + "snake": "🐍", + "lizard": "🦎", + "turtle": "🐢", + "tropical_fish": "🐠", + "fish": "🐟", + "blowfish": "🐡", + "dolphin": "🐬", + "shark": "🦈", + "whale": "🐳", + "whale2": "🐋", + "crocodile": "🐊", + "leopard": "🐆", + "tiger2": "🐅", + "water_buffalo": "🐃", + "ox": "🐂", + "cow2": "🐄", + "deer": "🦌", + "dromedary_camel": "🐪", + "camel": "🐫", + "elephant": "🐘", + "rhinoceros": "🦏", + "goat": "🐐", + "ram": "🐏", + "sheep": "🐑", + "racehorse": "🐎", + "pig2": "🐖", + "rat": "🐀", + "mouse2": "🐁", + "rooster": "🐓", + "turkey": "🦃", + "dove": "🕊", + "dog2": "🐕", + "poodle": "🐩", + "cat2": "🐈", + "rabbit2": "🐇", + "chipmunk": "🐿", + "paw_prints": "🐾", + "dragon": "🐉", + "dragon_face": "🐲", + "cactus": "🌵", + "christmas_tree": "🎄", + "evergreen_tree": "🌲", + "deciduous_tree": "🌳", + "palm_tree": "🌴", + "seedling": "🌱", + "herb": "🌿", + "shamrock": "☘", + "four_leaf_clover": "🍀", + "bamboo": "🎍", + "tanabata_tree": "🎋", + "leaves": "🍃", + "fallen_leaf": "🍂", + "maple_leaf": "🍁", + "ear_of_rice": "🌾", + "hibiscus": "🌺", + "sunflower": "🌻", + "rose": "🌹", + "wilted_flower": "🥀", + "tulip": "🌷", + "blossom": "🌼", + "cherry_blossom": "🌸", + "bouquet": "💐", + "mushroom": "🍄", + "chestnut": "🌰", + "jack_o_lantern": "🎃", + "shell": "🐚", + "spider_web": "🕸", + "earth_americas": "🌎", + "earth_africa": "🌍", + "earth_asia": "🌏", + "full_moon": "🌕", + "waning_gibbous_moon": "🌖", + "last_quarter_moon": "🌗", + "waning_crescent_moon": "🌘", + "new_moon": "🌑", + "waxing_crescent_moon": "🌒", + "first_quarter_moon": "🌓", + "waxing_gibbous_moon": "🌔", + "new_moon_with_face": "🌚", + "full_moon_with_face": "🌝", + "first_quarter_moon_with_face": "🌛", + "last_quarter_moon_with_face": "🌜", + "sun_with_face": "🌞", + "crescent_moon": "🌙", + "star": "⭐", + "star2": "🌟", + "dizzy": "💫", + "sparkles": "✨", + "comet": "☄", + "sunny": "☀️", + "sun_behind_small_cloud": "🌤", + "partly_sunny": "⛅", + "sun_behind_large_cloud": "🌥", + "sun_behind_rain_cloud": "🌦", + "cloud": "☁️", + "cloud_with_rain": "🌧", + "cloud_with_lightning_and_rain": "⛈", + "cloud_with_lightning": "🌩", + "zap": "⚡", + "fire": "🔥", + "boom": "💥", + "snowflake": "❄️", + "cloud_with_snow": "🌨", + "snowman": "⛄", + "snowman_with_snow": "☃", + "wind_face": "🌬", + "dash": "💨", + "tornado": "🌪", + "fog": "🌫", + "open_umbrella": "☂", + "umbrella": "☔", + "droplet": "💧", + "sweat_drops": "💦", + "ocean": "🌊", + "green_apple": "🍏", + "apple": "🍎", + "pear": "🍐", + "tangerine": "🍊", + "lemon": "🍋", + "banana": "🍌", + "watermelon": "🍉", + "grapes": "🍇", + "strawberry": "🍓", + "melon": "🍈", + "cherries": "🍒", + "peach": "🍑", + "pineapple": "🍍", + "kiwi_fruit": "🥝", + "avocado": "🥑", + "tomato": "🍅", + "eggplant": "🍆", + "cucumber": "🥒", + "carrot": "🥕", + "hot_pepper": "🌶", + "potato": "🥔", + "corn": "🌽", + "sweet_potato": "🍠", + "peanuts": "🥜", + "honey_pot": "🍯", + "croissant": "🥐", + "bread": "🍞", + "baguette_bread": "🥖", + "cheese": "🧀", + "egg": "🥚", + "bacon": "🥓", + "pancakes": "🥞", + "poultry_leg": "🍗", + "meat_on_bone": "🍖", + "fried_shrimp": "🍤", + "fried_egg": "🍳", + "hamburger": "🍔", + "fries": "🍟", + "stuffed_flatbread": "🥙", + "hotdog": "🌭", + "pizza": "🍕", + "spaghetti": "🍝", + "taco": "🌮", + "burrito": "🌯", + "green_salad": "🥗", + "shallow_pan_of_food": "🥘", + "ramen": "🍜", + "stew": "🍲", + "fish_cake": "🍥", + "sushi": "🍣", + "bento": "🍱", + "curry": "🍛", + "rice_ball": "🍙", + "rice": "🍚", + "rice_cracker": "🍘", + "oden": "🍢", + "dango": "🍡", + "shaved_ice": "🍧", + "ice_cream": "🍨", + "icecream": "🍦", + "cake": "🍰", + "birthday": "🎂", + "custard": "🍮", + "candy": "🍬", + "lollipop": "🍭", + "chocolate_bar": "🍫", + "popcorn": "🍿", + "doughnut": "🍩", + "cookie": "🍪", + "milk_glass": "🥛", + "beer": "🍺", + "beers": "🍻", + "clinking_glasses": "🥂", + "wine_glass": "🍷", + "tumbler_glass": "🥃", + "cocktail": "🍸", + "tropical_drink": "🍹", + "champagne": "🍾", + "sake": "🍶", + "tea": "🍵", + "coffee": "☕", + "baby_bottle": "🍼", + "spoon": "🥄", + "fork_and_knife": "🍴", + "plate_with_cutlery": "🍽", + "soccer": "⚽", + "basketball": "🏀", + "football": "🏈", + "baseball": "⚾", + "tennis": "🎾", + "volleyball": "🏐", + "rugby_football": "🏉", + "8ball": "🎱", + "golf": "⛳", + "golfing_woman": "🏌️‍♀️", + "golfing_man": "🏌", + "ping_pong": "🏓", + "badminton": "🏸", + "goal_net": "🥅", + "ice_hockey": "🏒", + "field_hockey": "🏑", + "cricket": "🏏", + "ski": "🎿", + "skier": "⛷", + "snowboarder": "🏂", + "person_fencing": "🤺", + "women_wrestling": "🤼‍♀️", + "men_wrestling": "🤼‍♂️", + "woman_cartwheeling": "🤸‍♀️", + "man_cartwheeling": "🤸‍♂️", + "woman_playing_handball": "🤾‍♀️", + "man_playing_handball": "🤾‍♂️", + "ice_skate": "⛸", + "bow_and_arrow": "🏹", + "fishing_pole_and_fish": "🎣", + "boxing_glove": "🥊", + "martial_arts_uniform": "🥋", + "rowing_woman": "🚣‍♀️", + "rowing_man": "🚣", + "swimming_woman": "🏊‍♀️", + "swimming_man": "🏊", + "woman_playing_water_polo": "🤽‍♀️", + "man_playing_water_polo": "🤽‍♂️", + "surfing_woman": "🏄‍♀️", + "surfing_man": "🏄", + "bath": "🛀", + "basketball_woman": "⛹️‍♀️", + "basketball_man": "⛹", + "weight_lifting_woman": "🏋️‍♀️", + "weight_lifting_man": "🏋", + "biking_woman": "🚴‍♀️", + "biking_man": "🚴", + "mountain_biking_woman": "🚵‍♀️", + "mountain_biking_man": "🚵", + "horse_racing": "🏇", + "business_suit_levitating": "🕴", + "trophy": "🏆", + "running_shirt_with_sash": "🎽", + "medal_sports": "🏅", + "medal_military": "🎖", + "1st_place_medal": "🥇", + "2nd_place_medal": "🥈", + "3rd_place_medal": "🥉", + "reminder_ribbon": "🎗", + "rosette": "🏵", + "ticket": "🎫", + "tickets": "🎟", + "performing_arts": "🎭", + "art": "🎨", + "circus_tent": "🎪", + "woman_juggling": "🤹‍♀️", + "man_juggling": "🤹‍♂️", + "microphone": "🎤", + "headphones": "🎧", + "musical_score": "🎼", + "musical_keyboard": "🎹", + "drum": "🥁", + "saxophone": "🎷", + "trumpet": "🎺", + "guitar": "🎸", + "violin": "🎻", + "clapper": "🎬", + "video_game": "🎮", + "space_invader": "👾", + "dart": "🎯", + "game_die": "🎲", + "slot_machine": "🎰", + "bowling": "🎳", + "red_car": "🚗", + "taxi": "🚕", + "blue_car": "🚙", + "bus": "🚌", + "trolleybus": "🚎", + "racing_car": "🏎", + "police_car": "🚓", + "ambulance": "🚑", + "fire_engine": "🚒", + "minibus": "🚐", + "truck": "🚚", + "articulated_lorry": "🚛", + "tractor": "🚜", + "kick_scooter": "🛴", + "motorcycle": "🏍", + "bike": "🚲", + "motor_scooter": "🛵", + "rotating_light": "🚨", + "oncoming_police_car": "🚔", + "oncoming_bus": "🚍", + "oncoming_automobile": "🚘", + "oncoming_taxi": "🚖", + "aerial_tramway": "🚡", + "mountain_cableway": "🚠", + "suspension_railway": "🚟", + "railway_car": "🚃", + "train": "🚋", + "monorail": "🚝", + "bullettrain_side": "🚄", + "bullettrain_front": "🚅", + "light_rail": "🚈", + "mountain_railway": "🚞", + "steam_locomotive": "🚂", + "train2": "🚆", + "metro": "🚇", + "tram": "🚊", + "station": "🚉", + "helicopter": "🚁", + "small_airplane": "🛩", + "airplane": "✈️", + "flight_departure": "🛫", + "flight_arrival": "🛬", + "sailboat": "⛵", + "motor_boat": "🛥", + "speedboat": "🚤", + "ferry": "⛴", + "passenger_ship": "🛳", + "rocket": "🚀", + "artificial_satellite": "🛰", + "seat": "💺", + "canoe": "🛶", + "anchor": "⚓", + "construction": "🚧", + "fuelpump": "⛽", + "busstop": "🚏", + "vertical_traffic_light": "🚦", + "traffic_light": "🚥", + "checkered_flag": "🏁", + "ship": "🚢", + "ferris_wheel": "🎡", + "roller_coaster": "🎢", + "carousel_horse": "🎠", + "building_construction": "🏗", + "foggy": "🌁", + "tokyo_tower": "🗼", + "factory": "🏭", + "fountain": "⛲", + "rice_scene": "🎑", + "mountain": "⛰", + "mountain_snow": "🏔", + "mount_fuji": "🗻", + "volcano": "🌋", + "japan": "🗾", + "camping": "🏕", + "tent": "⛺", + "national_park": "🏞", + "motorway": "🛣", + "railway_track": "🛤", + "sunrise": "🌅", + "sunrise_over_mountains": "🌄", + "desert": "🏜", + "beach_umbrella": "🏖", + "desert_island": "🏝", + "city_sunrise": "🌇", + "city_sunset": "🌆", + "cityscape": "🏙", + "night_with_stars": "🌃", + "bridge_at_night": "🌉", + "milky_way": "🌌", + "stars": "🌠", + "sparkler": "🎇", + "fireworks": "🎆", + "rainbow": "🌈", + "houses": "🏘", + "european_castle": "🏰", + "japanese_castle": "🏯", + "stadium": "🏟", + "statue_of_liberty": "🗽", + "house": "🏠", + "house_with_garden": "🏡", + "derelict_house": "🏚", + "office": "🏢", + "department_store": "🏬", + "post_office": "🏣", + "european_post_office": "🏤", + "hospital": "🏥", + "bank": "🏦", + "hotel": "🏨", + "convenience_store": "🏪", + "school": "🏫", + "love_hotel": "🏩", + "wedding": "💒", + "classical_building": "🏛", + "church": "⛪", + "mosque": "🕌", + "synagogue": "🕍", + "kaaba": "🕋", + "shinto_shrine": "⛩", + "watch": "⌚", + "iphone": "📱", + "calling": "📲", + "computer": "💻", + "keyboard": "⌨", + "desktop_computer": "🖥", + "printer": "🖨", + "computer_mouse": "🖱", + "trackball": "🖲", + "joystick": "🕹", + "clamp": "🗜", + "minidisc": "💽", + "floppy_disk": "💾", + "cd": "💿", + "dvd": "📀", + "vhs": "📼", + "camera": "📷", + "camera_flash": "📸", + "video_camera": "📹", + "movie_camera": "🎥", + "film_projector": "📽", + "film_strip": "🎞", + "telephone_receiver": "📞", + "phone": "☎️", + "pager": "📟", + "fax": "📠", + "tv": "📺", + "radio": "📻", + "studio_microphone": "🎙", + "level_slider": "🎚", + "control_knobs": "🎛", + "stopwatch": "⏱", + "timer_clock": "⏲", + "alarm_clock": "⏰", + "mantelpiece_clock": "🕰", + "hourglass_flowing_sand": "⏳", + "hourglass": "⌛", + "satellite": "📡", + "battery": "🔋", + "electric_plug": "🔌", + "bulb": "💡", + "flashlight": "🔦", + "candle": "🕯", + "wastebasket": "🗑", + "oil_drum": "🛢", + "money_with_wings": "💸", + "dollar": "💵", + "yen": "💴", + "euro": "💶", + "pound": "💷", + "moneybag": "💰", + "credit_card": "💳", + "gem": "💎", + "balance_scale": "⚖", + "wrench": "🔧", + "hammer": "🔨", + "hammer_and_pick": "⚒", + "hammer_and_wrench": "🛠", + "pick": "⛏", + "nut_and_bolt": "🔩", + "gear": "⚙", + "chains": "⛓", + "gun": "🔫", + "bomb": "💣", + "hocho": "🔪", + "dagger": "🗡", + "crossed_swords": "⚔", + "shield": "🛡", + "smoking": "🚬", + "skull_and_crossbones": "☠", + "coffin": "⚰", + "funeral_urn": "⚱", + "amphora": "🏺", + "crystal_ball": "🔮", + "prayer_beads": "📿", + "barber": "💈", + "alembic": "⚗", + "telescope": "🔭", + "microscope": "🔬", + "hole": "🕳", + "pill": "💊", + "syringe": "💉", + "thermometer": "🌡", + "label": "🏷", + "bookmark": "🔖", + "toilet": "🚽", + "shower": "🚿", + "bathtub": "🛁", + "key": "🔑", + "old_key": "🗝", + "couch_and_lamp": "🛋", + "sleeping_bed": "🛌", + "bed": "🛏", + "door": "🚪", + "bellhop_bell": "🛎", + "framed_picture": "🖼", + "world_map": "🗺", + "parasol_on_ground": "⛱", + "moyai": "🗿", + "shopping": "🛍", + "shopping_cart": "🛒", + "balloon": "🎈", + "flags": "🎏", + "ribbon": "🎀", + "gift": "🎁", + "confetti_ball": "🎊", + "tada": "🎉", + "dolls": "🎎", + "wind_chime": "🎐", + "crossed_flags": "🎌", + "izakaya_lantern": "🏮", + "email": "✉️", + "envelope_with_arrow": "📩", + "incoming_envelope": "📨", + "e-mail": "📧", + "love_letter": "💌", + "postbox": "📮", + "mailbox_closed": "📪", + "mailbox": "📫", + "mailbox_with_mail": "📬", + "mailbox_with_no_mail": "📭", + "package": "📦", + "postal_horn": "📯", + "inbox_tray": "📥", + "outbox_tray": "📤", + "scroll": "📜", + "page_with_curl": "📃", + "bookmark_tabs": "📑", + "bar_chart": "📊", + "chart_with_upwards_trend": "📈", + "chart_with_downwards_trend": "📉", + "page_facing_up": "📄", + "date": "📅", + "calendar": "📆", + "spiral_calendar": "🗓", + "card_index": "📇", + "card_file_box": "🗃", + "ballot_box": "🗳", + "file_cabinet": "🗄", + "clipboard": "📋", + "spiral_notepad": "🗒", + "file_folder": "📁", + "open_file_folder": "📂", + "card_index_dividers": "🗂", + "newspaper_roll": "🗞", + "newspaper": "📰", + "notebook": "📓", + "closed_book": "📕", + "green_book": "📗", + "blue_book": "📘", + "orange_book": "📙", + "notebook_with_decorative_cover": "📔", + "ledger": "📒", + "books": "📚", + "open_book": "📖", + "link": "🔗", + "paperclip": "📎", + "paperclips": "🖇", + "scissors": "✂️", + "triangular_ruler": "📐", + "straight_ruler": "📏", + "pushpin": "📌", + "round_pushpin": "📍", + "triangular_flag_on_post": "🚩", + "white_flag": "🏳", + "black_flag": "🏴", + "rainbow_flag": "🏳️‍🌈", + "closed_lock_with_key": "🔐", + "lock": "🔒", + "unlock": "🔓", + "lock_with_ink_pen": "🔏", + "pen": "🖊", + "fountain_pen": "🖋", + "black_nib": "✒️", + "memo": "📝", + "pencil2": "✏️", + "crayon": "🖍", + "paintbrush": "🖌", + "mag": "🔍", + "mag_right": "🔎", + "heart": "❤️", + "yellow_heart": "💛", + "green_heart": "💚", + "blue_heart": "💙", + "purple_heart": "💜", + "black_heart": "🖤", + "broken_heart": "💔", + "heavy_heart_exclamation": "❣", + "two_hearts": "💕", + "revolving_hearts": "💞", + "heartbeat": "💓", + "heartpulse": "💗", + "sparkling_heart": "💖", + "cupid": "💘", + "gift_heart": "💝", + "heart_decoration": "💟", + "peace_symbol": "☮", + "latin_cross": "✝", + "star_and_crescent": "☪", + "om": "🕉", + "wheel_of_dharma": "☸", + "star_of_david": "✡", + "six_pointed_star": "🔯", + "menorah": "🕎", + "yin_yang": "☯", + "orthodox_cross": "☦", + "place_of_worship": "🛐", + "ophiuchus": "⛎", + "aries": "♈", + "taurus": "♉", + "gemini": "♊", + "cancer": "♋", + "leo": "♌", + "virgo": "♍", + "libra": "♎", + "scorpius": "♏", + "sagittarius": "♐", + "capricorn": "♑", + "aquarius": "♒", + "pisces": "♓", + "id": "🆔", + "atom_symbol": "⚛", + "u7a7a": "🈳", + "u5272": "🈹", + "radioactive": "☢", + "biohazard": "☣", + "mobile_phone_off": "📴", + "vibration_mode": "📳", + "u6709": "🈶", + "u7121": "🈚", + "u7533": "🈸", + "u55b6": "🈺", + "u6708": "🈷️", + "eight_pointed_black_star": "✴️", + "vs": "🆚", + "accept": "🉑", + "white_flower": "💮", + "ideograph_advantage": "🉐", + "secret": "㊙️", + "congratulations": "㊗️", + "u5408": "🈴", + "u6e80": "🈵", + "u7981": "🈲", + "a": "🅰️", + "b": "🅱️", + "ab": "🆎", + "cl": "🆑", + "o2": "🅾️", + "sos": "🆘", + "no_entry": "⛔", + "name_badge": "📛", + "no_entry_sign": "🚫", + "x": "❌", + "o": "⭕", + "stop_sign": "🛑", + "anger": "💢", + "hotsprings": "♨️", + "no_pedestrians": "🚷", + "do_not_litter": "🚯", + "no_bicycles": "🚳", + "non-potable_water": "🚱", + "underage": "🔞", + "no_mobile_phones": "📵", + "exclamation": "❗", + "grey_exclamation": "❕", + "question": "❓", + "grey_question": "❔", + "bangbang": "‼️", + "interrobang": "⁉️", + "low_brightness": "🔅", + "high_brightness": "🔆", + "trident": "🔱", + "fleur_de_lis": "⚜", + "part_alternation_mark": "〽️", + "warning": "⚠️", + "children_crossing": "🚸", + "beginner": "🔰", + "recycle": "♻️", + "u6307": "🈯", + "chart": "💹", + "sparkle": "❇️", + "eight_spoked_asterisk": "✳️", + "negative_squared_cross_mark": "❎", + "white_check_mark": "✅", + "diamond_shape_with_a_dot_inside": "💠", + "cyclone": "🌀", + "loop": "➿", + "globe_with_meridians": "🌐", + "m": "Ⓜ️", + "atm": "🏧", + "sa": "🈂️", + "passport_control": "🛂", + "customs": "🛃", + "baggage_claim": "🛄", + "left_luggage": "🛅", + "wheelchair": "♿", + "no_smoking": "🚭", + "wc": "🚾", + "parking": "🅿️", + "potable_water": "🚰", + "mens": "🚹", + "womens": "🚺", + "baby_symbol": "🚼", + "restroom": "🚻", + "put_litter_in_its_place": "🚮", + "cinema": "🎦", + "signal_strength": "📶", + "koko": "🈁", + "ng": "🆖", + "ok": "🆗", + "up": "🆙", + "cool": "🆒", + "new": "🆕", + "free": "🆓", + "zero": "0️⃣", + "one": "1️⃣", + "two": "2️⃣", + "three": "3️⃣", + "four": "4️⃣", + "five": "5️⃣", + "six": "6️⃣", + "seven": "7️⃣", + "eight": "8️⃣", + "nine": "9️⃣", + "keycap_ten": "🔟", + "asterisk": "*⃣", + "arrow_forward": "▶️", + "pause_button": "⏸", + "next_track_button": "⏭", + "stop_button": "⏹", + "record_button": "⏺", + "play_or_pause_button": "⏯", + "previous_track_button": "⏮", + "fast_forward": "⏩", + "rewind": "⏪", + "twisted_rightwards_arrows": "🔀", + "repeat": "🔁", + "repeat_one": "🔂", + "arrow_backward": "◀️", + "arrow_up_small": "🔼", + "arrow_down_small": "🔽", + "arrow_double_up": "⏫", + "arrow_double_down": "⏬", + "arrow_right": "➡️", + "arrow_left": "⬅️", + "arrow_up": "⬆️", + "arrow_down": "⬇️", + "arrow_upper_right": "↗️", + "arrow_lower_right": "↘️", + "arrow_lower_left": "↙️", + "arrow_upper_left": "↖️", + "arrow_up_down": "↕️", + "left_right_arrow": "↔️", + "arrows_counterclockwise": "🔄", + "arrow_right_hook": "↪️", + "leftwards_arrow_with_hook": "↩️", + "arrow_heading_up": "⤴️", + "arrow_heading_down": "⤵️", + "hash": "#️⃣", + "information_source": "ℹ️", + "abc": "🔤", + "abcd": "🔡", + "capital_abcd": "🔠", + "symbols": "🔣", + "musical_note": "🎵", + "notes": "🎶", + "wavy_dash": "〰️", + "curly_loop": "➰", + "heavy_check_mark": "✔️", + "arrows_clockwise": "🔃", + "heavy_plus_sign": "➕", + "heavy_minus_sign": "➖", + "heavy_division_sign": "➗", + "heavy_multiplication_x": "✖️", + "heavy_dollar_sign": "💲", + "currency_exchange": "💱", + "copyright": "©️", + "registered": "®️", + "tm": "™️", + "end": "🔚", + "back": "🔙", + "on": "🔛", + "top": "🔝", + "soon": "🔜", + "ballot_box_with_check": "☑️", + "radio_button": "🔘", + "white_circle": "⚪", + "black_circle": "⚫", + "red_circle": "🔴", + "large_blue_circle": "🔵", + "small_orange_diamond": "🔸", + "small_blue_diamond": "🔹", + "large_orange_diamond": "🔶", + "large_blue_diamond": "🔷", + "small_red_triangle": "🔺", + "black_small_square": "▪️", + "white_small_square": "▫️", + "black_large_square": "⬛", + "white_large_square": "⬜", + "small_red_triangle_down": "🔻", + "black_medium_square": "◼️", + "white_medium_square": "◻️", + "black_medium_small_square": "◾", + "white_medium_small_square": "◽", + "black_square_button": "🔲", + "white_square_button": "🔳", + "speaker": "🔈", + "sound": "🔉", + "loud_sound": "🔊", + "mute": "🔇", + "mega": "📣", + "loudspeaker": "📢", + "bell": "🔔", + "no_bell": "🔕", + "black_joker": "🃏", + "mahjong": "🀄", + "spades": "♠️", + "clubs": "♣️", + "hearts": "♥️", + "diamonds": "♦️", + "flower_playing_cards": "🎴", + "thought_balloon": "💭", + "right_anger_bubble": "🗯", + "speech_balloon": "💬", + "left_speech_bubble": "🗨", + "clock1": "🕐", + "clock2": "🕑", + "clock3": "🕒", + "clock4": "🕓", + "clock5": "🕔", + "clock6": "🕕", + "clock7": "🕖", + "clock8": "🕗", + "clock9": "🕘", + "clock10": "🕙", + "clock11": "🕚", + "clock12": "🕛", + "clock130": "🕜", + "clock230": "🕝", + "clock330": "🕞", + "clock430": "🕟", + "clock530": "🕠", + "clock630": "🕡", + "clock730": "🕢", + "clock830": "🕣", + "clock930": "🕤", + "clock1030": "🕥", + "clock1130": "🕦", + "clock1230": "🕧", + "afghanistan": "🇦🇫", + "aland_islands": "🇦🇽", + "albania": "🇦🇱", + "algeria": "🇩🇿", + "american_samoa": "🇦🇸", + "andorra": "🇦🇩", + "angola": "🇦🇴", + "anguilla": "🇦🇮", + "antarctica": "🇦🇶", + "antigua_barbuda": "🇦🇬", + "argentina": "🇦🇷", + "armenia": "🇦🇲", + "aruba": "🇦🇼", + "australia": "🇦🇺", + "austria": "🇦🇹", + "azerbaijan": "🇦🇿", + "bahamas": "🇧🇸", + "bahrain": "🇧🇭", + "bangladesh": "🇧🇩", + "barbados": "🇧🇧", + "belarus": "🇧🇾", + "belgium": "🇧🇪", + "belize": "🇧🇿", + "benin": "🇧🇯", + "bermuda": "🇧🇲", + "bhutan": "🇧🇹", + "bolivia": "🇧🇴", + "caribbean_netherlands": "🇧🇶", + "bosnia_herzegovina": "🇧🇦", + "botswana": "🇧🇼", + "brazil": "🇧🇷", + "british_indian_ocean_territory": "🇮🇴", + "british_virgin_islands": "🇻🇬", + "brunei": "🇧🇳", + "bulgaria": "🇧🇬", + "burkina_faso": "🇧🇫", + "burundi": "🇧🇮", + "cape_verde": "🇨🇻", + "cambodia": "🇰🇭", + "cameroon": "🇨🇲", + "canada": "🇨🇦", + "canary_islands": "🇮🇨", + "cayman_islands": "🇰🇾", + "central_african_republic": "🇨🇫", + "chad": "🇹🇩", + "chile": "🇨🇱", + "cn": "🇨🇳", + "christmas_island": "🇨🇽", + "cocos_islands": "🇨🇨", + "colombia": "🇨🇴", + "comoros": "🇰🇲", + "congo_brazzaville": "🇨🇬", + "congo_kinshasa": "🇨🇩", + "cook_islands": "🇨🇰", + "costa_rica": "🇨🇷", + "croatia": "🇭🇷", + "cuba": "🇨🇺", + "curacao": "🇨🇼", + "cyprus": "🇨🇾", + "czech_republic": "🇨🇿", + "denmark": "🇩🇰", + "djibouti": "🇩🇯", + "dominica": "🇩🇲", + "dominican_republic": "🇩🇴", + "ecuador": "🇪🇨", + "egypt": "🇪🇬", + "el_salvador": "🇸🇻", + "equatorial_guinea": "🇬🇶", + "eritrea": "🇪🇷", + "estonia": "🇪🇪", + "ethiopia": "🇪🇹", + "eu": "🇪🇺", + "falkland_islands": "🇫🇰", + "faroe_islands": "🇫🇴", + "fiji": "🇫🇯", + "finland": "🇫🇮", + "fr": "🇫🇷", + "french_guiana": "🇬🇫", + "french_polynesia": "🇵🇫", + "french_southern_territories": "🇹🇫", + "gabon": "🇬🇦", + "gambia": "🇬🇲", + "georgia": "🇬🇪", + "de": "🇩🇪", + "ghana": "🇬🇭", + "gibraltar": "🇬🇮", + "greece": "🇬🇷", + "greenland": "🇬🇱", + "grenada": "🇬🇩", + "guadeloupe": "🇬🇵", + "guam": "🇬🇺", + "guatemala": "🇬🇹", + "guernsey": "🇬🇬", + "guinea": "🇬🇳", + "guinea_bissau": "🇬🇼", + "guyana": "🇬🇾", + "haiti": "🇭🇹", + "honduras": "🇭🇳", + "hong_kong": "🇭🇰", + "hungary": "🇭🇺", + "iceland": "🇮🇸", + "india": "🇮🇳", + "indonesia": "🇮🇩", + "iran": "🇮🇷", + "iraq": "🇮🇶", + "ireland": "🇮🇪", + "isle_of_man": "🇮🇲", + "israel": "🇮🇱", + "it": "🇮🇹", + "cote_divoire": "🇨🇮", + "jamaica": "🇯🇲", + "jp": "🇯🇵", + "jersey": "🇯🇪", + "jordan": "🇯🇴", + "kazakhstan": "🇰🇿", + "kenya": "🇰🇪", + "kiribati": "🇰🇮", + "kosovo": "🇽🇰", + "kuwait": "🇰🇼", + "kyrgyzstan": "🇰🇬", + "laos": "🇱🇦", + "latvia": "🇱🇻", + "lebanon": "🇱🇧", + "lesotho": "🇱🇸", + "liberia": "🇱🇷", + "libya": "🇱🇾", + "liechtenstein": "🇱🇮", + "lithuania": "🇱🇹", + "luxembourg": "🇱🇺", + "macau": "🇲🇴", + "macedonia": "🇲🇰", + "madagascar": "🇲🇬", + "malawi": "🇲🇼", + "malaysia": "🇲🇾", + "maldives": "🇲🇻", + "mali": "🇲🇱", + "malta": "🇲🇹", + "marshall_islands": "🇲🇭", + "martinique": "🇲🇶", + "mauritania": "🇲🇷", + "mauritius": "🇲🇺", + "mayotte": "🇾🇹", + "mexico": "🇲🇽", + "micronesia": "🇫🇲", + "moldova": "🇲🇩", + "monaco": "🇲🇨", + "mongolia": "🇲🇳", + "montenegro": "🇲🇪", + "montserrat": "🇲🇸", + "morocco": "🇲🇦", + "mozambique": "🇲🇿", + "myanmar": "🇲🇲", + "namibia": "🇳🇦", + "nauru": "🇳🇷", + "nepal": "🇳🇵", + "netherlands": "🇳🇱", + "new_caledonia": "🇳🇨", + "new_zealand": "🇳🇿", + "nicaragua": "🇳🇮", + "niger": "🇳🇪", + "nigeria": "🇳🇬", + "niue": "🇳🇺", + "norfolk_island": "🇳🇫", + "northern_mariana_islands": "🇲🇵", + "north_korea": "🇰🇵", + "norway": "🇳🇴", + "oman": "🇴🇲", + "pakistan": "🇵🇰", + "palau": "🇵🇼", + "palestinian_territories": "🇵🇸", + "panama": "🇵🇦", + "papua_new_guinea": "🇵🇬", + "paraguay": "🇵🇾", + "peru": "🇵🇪", + "philippines": "🇵🇭", + "pitcairn_islands": "🇵🇳", + "poland": "🇵🇱", + "portugal": "🇵🇹", + "puerto_rico": "🇵🇷", + "qatar": "🇶🇦", + "reunion": "🇷🇪", + "romania": "🇷🇴", + "ru": "🇷🇺", + "rwanda": "🇷🇼", + "st_barthelemy": "🇧🇱", + "st_helena": "🇸🇭", + "st_kitts_nevis": "🇰🇳", + "st_lucia": "🇱🇨", + "st_pierre_miquelon": "🇵🇲", + "st_vincent_grenadines": "🇻🇨", + "samoa": "🇼🇸", + "san_marino": "🇸🇲", + "sao_tome_principe": "🇸🇹", + "saudi_arabia": "🇸🇦", + "senegal": "🇸🇳", + "serbia": "🇷🇸", + "seychelles": "🇸🇨", + "sierra_leone": "🇸🇱", + "singapore": "🇸🇬", + "sint_maarten": "🇸🇽", + "slovakia": "🇸🇰", + "slovenia": "🇸🇮", + "solomon_islands": "🇸🇧", + "somalia": "🇸🇴", + "south_africa": "🇿🇦", + "south_georgia_south_sandwich_islands": "🇬🇸", + "kr": "🇰🇷", + "south_sudan": "🇸🇸", + "es": "🇪🇸", + "sri_lanka": "🇱🇰", + "sudan": "🇸🇩", + "suriname": "🇸🇷", + "swaziland": "🇸🇿", + "sweden": "🇸🇪", + "switzerland": "🇨🇭", + "syria": "🇸🇾", + "taiwan": "🇹🇼", + "tajikistan": "🇹🇯", + "tanzania": "🇹🇿", + "thailand": "🇹🇭", + "timor_leste": "🇹🇱", + "togo": "🇹🇬", + "tokelau": "🇹🇰", + "tonga": "🇹🇴", + "trinidad_tobago": "🇹🇹", + "tunisia": "🇹🇳", + "tr": "🇹🇷", + "turkmenistan": "🇹🇲", + "turks_caicos_islands": "🇹🇨", + "tuvalu": "🇹🇻", + "uganda": "🇺🇬", + "ukraine": "🇺🇦", + "united_arab_emirates": "🇦🇪", + "uk": "🇬🇧", + "us": "🇺🇸", + "us_virgin_islands": "🇻🇮", + "uruguay": "🇺🇾", + "uzbekistan": "🇺🇿", + "vanuatu": "🇻🇺", + "vatican_city": "🇻🇦", + "venezuela": "🇻🇪", + "vietnam": "🇻🇳", + "wallis_futuna": "🇼🇫", + "western_sahara": "🇪🇭", + "yemen": "🇾🇪", + "zambia": "🇿🇲", + "zimbabwe": "🇿🇼" +} \ No newline at end of file diff --git a/client/js/libs/slideout.js b/client/js/libs/slideout.js index 522cc448..93ec86c1 100644 --- a/client/js/libs/slideout.js +++ b/client/js/libs/slideout.js @@ -1,7 +1,9 @@ +"use strict"; + /** * Simple slideout menu implementation. */ -export default function slideoutMenu(viewport, menu) { +module.exports = function slideoutMenu(viewport, menu) { var touchStartPos = null; var touchCurPos = null; var touchStartTime = 0; @@ -21,7 +23,7 @@ export default function slideoutMenu(viewport, menu) { function onTouchStart(e) { if (e.touches.length !== 1) { onTouchEnd(); - return false; + return; } var touch = e.touches.item(0); @@ -35,7 +37,7 @@ export default function slideoutMenu(viewport, menu) { touchStartTime = Date.now(); 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; } - viewport.addEventListener("touchstart", onTouchStart); + viewport.addEventListener("touchstart", onTouchStart, {passive: true}); return { disable: disableSlideout, @@ -98,4 +100,4 @@ export default function slideoutMenu(viewport, menu) { return menuIsOpen; } }; -} +}; diff --git a/client/js/loading-slow-alert.js b/client/js/loading-slow-alert.js index 9709fd86..debff175 100644 --- a/client/js/loading-slow-alert.js +++ b/client/js/loading-slow-alert.js @@ -15,3 +15,7 @@ setTimeout(function() { element.style.display = "block"; } }, 5000); + +document.getElementById("loading-slow-reload").addEventListener("click", function() { + location.reload(); +}); diff --git a/client/js/localStorage.js b/client/js/localStorage.js new file mode 100644 index 00000000..3b08b3bd --- /dev/null +++ b/client/js/localStorage.js @@ -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); + } +}; diff --git a/client/js/lounge.js b/client/js/lounge.js index 81275fbd..99b0b91c 100644 --- a/client/js/lounge.js +++ b/client/js/lounge.js @@ -1,61 +1,32 @@ +"use strict"; + // vendor libraries -import "jquery-ui/ui/widgets/sortable"; -import $ from "jquery"; -import io from "socket.io-client"; -import Mousetrap from "mousetrap"; -import URI from "urijs"; +require("jquery-ui/ui/widgets/sortable"); +const $ = require("jquery"); +const moment = require("moment"); +const Mousetrap = require("mousetrap"); +const URI = require("urijs"); +const fuzzy = require("fuzzy"); // our libraries -import "./libs/jquery/inputhistory"; -import "./libs/jquery/stickyscroll"; -import "./libs/jquery/tabcomplete"; -import helpers_parse from "./libs/handlebars/parse"; -import helpers_roundBadgeNumber from "./libs/handlebars/roundBadgeNumber"; -import slideoutMenu from "./libs/slideout"; -import templates from "../views"; +require("./libs/jquery/inputhistory"); +require("./libs/jquery/stickyscroll"); +const helpers_roundBadgeNumber = require("./libs/handlebars/roundBadgeNumber"); +const slideoutMenu = require("./libs/slideout"); +const templates = require("../views"); +const socket = require("./socket"); +require("./socket-events"); +const storage = require("./localStorage"); +const options = require("./options"); +const utils = require("./utils"); +require("./autocompletion"); +require("./webpush"); $(function() { - var path = window.location.pathname + "socket.io/"; - var socket = io({ - path: path, - autoConnect: false, - reconnection: false - }); - var commands = [ - "/away", - "/back", - "/close", - "/connect", - "/deop", - "/devoice", - "/disconnect", - "/invite", - "/join", - "/kick", - "/leave", - "/me", - "/mode", - "/msg", - "/nick", - "/notice", - "/op", - "/part", - "/query", - "/quit", - "/raw", - "/say", - "/send", - "/server", - "/slap", - "/topic", - "/voice", - "/whois" - ]; - var sidebar = $("#sidebar, #footer"); var chat = $("#chat"); - var ignoreSortSync = false; + $(document.body).data("app-name", document.title); var pop; try { @@ -71,647 +42,7 @@ $(function() { pop.play(); }); - var favicon = $("#favicon"); - - function setLocalStorageItem(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. - } - } - - [ - "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…"); - }); - - socket.on("auth", function(data) { - var login = $("#sign-in"); - var token; - - login.find(".btn").prop("disabled", false); - - if (!data.success) { - window.localStorage.removeItem("token"); - - var error = login.find(".error"); - error.show().closest("form").one("submit", function() { - error.hide(); - }); - } else { - token = window.localStorage.getItem("token"); - if (token) { - $("#loading-page-message").text("Authorizing…"); - socket.emit("auth", {token: token}); - } - } - - var input = login.find("input[name='user']"); - if (input.val() === "") { - input.val(window.localStorage.getItem("user") || ""); - } - if (token) { - return; - } - sidebar.find(".sign-in") - .click() - .end() - .find(".networks") - .html("") - .next() - .show(); - }); - - socket.on("change-password", function(data) { - var passwordForm = $("#change-password"); - if (data.error || data.success) { - var message = data.success ? data.success : data.error; - var 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(); - }); - } - - if (data.token && window.localStorage.getItem("token") !== null) { - setLocalStorageItem("token", data.token); - } - - passwordForm - .find("input") - .val("") - .end() - .find(".btn") - .prop("disabled", false); - }); - - socket.on("init", function(data) { - $("#loading-page-message").text("Rendering…"); - - if (data.networks.length === 0) { - $("#footer").find(".connect").trigger("click"); - } else { - renderNetworks(data); - } - - if (data.token && $("#sign-in-remember").is(":checked")) { - setLocalStorageItem("token", data.token); - } else { - window.localStorage.removeItem("token"); - } - - $("body").removeClass("signed-out"); - $("#loading").remove(); - $("#sign-in").remove(); - - var id = data.active; - var target = sidebar.find("[data-id='" + id + "']").trigger("click"); - if (target.length === 0) { - var first = sidebar.find(".chan") - .eq(0) - .trigger("click"); - if (first.length === 0) { - $("#footer").find(".connect").trigger("click"); - } - } - }); - - socket.on("open", function(id) { - // Another client opened the channel, clear the unread counter - sidebar.find(".chan[data-id='" + id + "'] .badge") - .removeClass("highlight") - .empty(); - }); - - socket.on("join", function(data) { - var id = data.network; - var network = sidebar.find("#network-" + id); - network.append( - templates.chan({ - channels: [data.chan] - }) - ); - chat.append( - templates.chat({ - channels: [data.chan] - }) - ); - 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(); - }); - - function buildChatMessage(data) { - var type = data.msg.type; - var target = "#chan-" + data.chan; - if (type === "error") { - target = "#chan-" + chat.find(".active").data("id"); - } - - var chan = chat.find(target); - var template = "msg"; - - if (!data.msg.highlight && !data.msg.self && (type === "message" || type === "notice") && highlights.some(function(h) { - return data.msg.text.toLocaleLowerCase().indexOf(h.toLocaleLowerCase()) > -1; - })) { - data.msg.highlight = true; - } - - if ([ - "invite", - "join", - "mode", - "kick", - "nick", - "part", - "quit", - "topic", - "topic_set_by", - "action", - "whois", - "ctcp", - "channel_list", - ].indexOf(type) !== -1) { - template = "msg_action"; - } else if (type === "unhandled") { - template = "msg_unhandled"; - } - - var msg = $(templates[template](data.msg)); - var text = msg.find(".text"); - - if (template === "msg_action") { - text.html(templates.actions[type](data.msg)); - } - - if ((type === "message" || type === "action") && chan.hasClass("channel")) { - var nicks = chan.find(".users").data("nicks"); - if (nicks) { - var find = nicks.indexOf(data.msg.from); - if (find !== -1 && typeof move === "function") { - move(nicks, find, 0); - } - } - } - - return msg; - } - - function buildChannelMessages(channel, messages) { - return messages.reduce(function(docFragment, message) { - docFragment.append(buildChatMessage({ - chan: channel, - msg: message - })); - return docFragment; - }, $(document.createDocumentFragment())); - } - - function renderChannel(data) { - renderChannelMessages(data); - renderChannelUsers(data); - } - - function renderChannelMessages(data) { - var documentFragment = buildChannelMessages(data.id, data.messages); - var channel = chat.find("#chan-" + data.id + " .messages").append(documentFragment); - - if (data.firstUnread > 0) { - var 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 { - first.before(templates.unread_marker()); - } - } else { - channel.append(templates.unread_marker()); - } - - if (data.type !== "lobby") { - var lastDate; - $(chat.find("#chan-" + data.id + " .messages .msg[data-time]")).each(function() { - var msg = $(this); - var 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()) { - msg.before(templates.date_marker({msgDate: msgDate})); - } - - lastDate = msgDate; - }); - } - - } - - function renderChannelUsers(data) { - var users = chat.find("#chan-" + data.id).find(".users"); - var nicks = users.data("nicks") || []; - var i, oldSortOrder = {}; - - for (i in nicks) { - oldSortOrder[nicks[i]] = i; - } - - nicks = []; - - for (i in data.users) { - nicks.push(data.users[i].name); - } - - nicks = nicks.sort(function(a, b) { - return (oldSortOrder[a] || Number.MAX_VALUE) - (oldSortOrder[b] || Number.MAX_VALUE); - }); - - users.html(templates.user(data)).data("nicks", nicks); - } - - function renderNetworks(data) { - sidebar.find(".empty").hide(); - sidebar.find(".networks").append( - templates.network({ - networks: data.networks - }) - ); - - var channels = $.map(data.networks, function(n) { - return n.channels; - }); - chat.append( - templates.chat({ - channels: channels - }) - ); - channels.forEach(renderChannel); - - confirmExit(); - sortable(); - - if (sidebar.find(".highlight").length) { - toggleNotificationMarkers(true); - } - } - - socket.on("msg", function(data) { - var msg = buildChatMessage(data); - var target = "#chan-" + data.chan; - var container = chat.find(target + " .messages"); - - // Check if date changed - var prevMsg = $(container.find(".msg")).last(); - var prevMsgTime = new Date(prevMsg.attr("data-time")); - var 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()) { - prevMsg.append(templates.date_marker({msgDate: msgTime})); - } - - // Add message to the container - container - .append(msg) - .trigger("msg", [ - target, - data - ]); - - if (data.msg.self) { - container - .find(".unread-marker") - .appendTo(container); - } - }); - - socket.on("more", function(data) { - var documentFragment = buildChannelMessages(data.chan, data.messages); - var chan = chat - .find("#chan-" + data.chan) - .find(".messages"); - - // Remove the date-change marker we put at the top, because it may - // not actually be a date change now - var children = $(chan).children(); - if (children.eq(0).hasClass("date-marker")) { // Check top most child - children.eq(0).remove(); - } else if (children.eq(0).hasClass("unread-marker") && children.eq(1).hasClass("date-marker")) { - // Otherwise the date-marker would get 'stuck' because of the new-message marker - children.eq(1).remove(); - } - - // get the scrollable wrapper around messages - var scrollable = chan.closest(".chat"); - var heightOld = chan.height(); - chan.prepend(documentFragment).end(); - - // restore scroll position - var 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 instaid of the documentFragment because it's being weird - var lastDate; - $(data.messages).each(function() { - var msgData = this; - var msgDate = new Date(msgData.time); - var 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()) { - msg.before(templates.date_marker({msgDate: msgDate})); - } - - lastDate = msgDate; - }); - - }); - - socket.on("network", function(data) { - 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); - }); - - socket.on("nick", function(data) { - var id = data.network; - var nick = data.nick; - var network = sidebar.find("#network-" + id).data("nick", nick); - if (network.find(".active").length) { - setNick(nick); - } - }); - - socket.on("part", function(data) { - var 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(); - }); - - socket.on("quit", function(data) { - var id = data.network; - sidebar.find("#network-" + id) - .remove() - .end(); - var chan = sidebar.find(".chan") - .eq(0) - .trigger("click"); - if (chan.length === 0) { - sidebar.find(".empty").show(); - } - }); - - socket.on("toggle", function(data) { - var toggle = $("#toggle-" + data.id); - toggle.parent().after(templates.toggle({toggle: data})); - switch (data.type) { - case "link": - if (options.links) { - toggle.click(); - } - break; - - case "image": - if (options.thumbnails) { - toggle.click(); - } - break; - } - }); - - socket.on("topic", function(data) { - var 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); - }); - - socket.on("users", function(data) { - var chan = chat.find("#chan-" + data.chan); - - if (chan.hasClass("active")) { - socket.emit("names", { - target: data.chan - }); - } else { - chan.data("needsNamesRefresh", true); - } - }); - - socket.on("names", renderChannelUsers); - - var userStyles = $("#user-specified-css"); - var highlights = []; - var options = $.extend({ - coloredNicks: true, - desktopNotifications: false, - join: true, - links: true, - mode: true, - motd: false, - nick: true, - notification: true, - notifyAllMessages: false, - part: true, - quit: true, - theme: $("#theme").attr("href").replace(/^themes\/(.*).css$/, "$1"), // Extracts default theme name, set on the server configuration - thumbnails: true, - userStyles: userStyles.text(), - }, JSON.parse(window.localStorage.getItem("settings"))); - var windows = $("#windows"); - - (function SettingsScope() { - var settings = $("#settings"); - - 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 === "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() { - var self = $(this); - var name = self.attr("name"); - - if (self.attr("type") === "checkbox") { - options[name] = self.prop("checked"); - } else { - options[name] = self.val(); - } - - setLocalStorageItem("settings", JSON.stringify(options)); - - if ([ - "join", - "mode", - "motd", - "nick", - "part", - "quit", - "notifyAllMessages", - ].indexOf(name) !== -1) { - chat.toggleClass("hide-" + name, !self.prop("checked")); - } 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]; - 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 !== ""; - }); - } - }).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); - } - }()); - var viewport = $("#viewport"); var sidebarSlide = slideoutMenu(viewport[0], sidebar[0]); var contextMenuContainer = $("#context-menu-container"); @@ -729,6 +60,7 @@ $(function() { var self = $(this); viewport.toggleClass(self.attr("class")); e.stopPropagation(); + chat.find(".chan.active .chat").trigger("msg.sticky"); }); function positionContextMenu(that, e) { @@ -808,7 +140,7 @@ $(function() { var input = $("#input") .history() - .on("input keyup", function() { + .on("input", function() { var style = window.getComputedStyle(this); // Start by resetting height before computing as scrollHeight does not @@ -822,9 +154,8 @@ $(function() { + Math.round(parseFloat(style.borderBottomWidth) || 0) ) + "px"; - $("#chat .chan.active .chat").trigger("msg.sticky"); // fix growing - }) - .tab(complete, {hint: false}); + chat.find(".chan.active .chat").trigger("msg.sticky"); // fix growing + }); var focus = $.noop; if (!("ontouchstart" in window || navigator.maxTouchPoints > 0)) { @@ -851,21 +182,9 @@ $(function() { }); } - // Triggering click event opens the virtual keyboard on mobile - // This can only be called from another interactive event (e.g. button click) - var forceFocus = function() { - input.trigger("click").focus(); - }; - - // Cycle through nicks for the current word, just like hitting "Tab" - $("#cycle-nicks").on("click", function() { - input.triggerHandler($.Event("keydown.tabcomplete", {which: 9})); - forceFocus(); - }); - $("#form").on("submit", function(e) { e.preventDefault(); - forceFocus(); + utils.forceFocus(); var text = input.val(); if (text.length === 0) { @@ -876,7 +195,17 @@ $(function() { resetInputHeight(input.get(0)); if (text.indexOf("/clear") === 0) { - clear(); + utils.clear(); + return; + } + + if (text.indexOf("/collapse") === 0) { + $(".chan.active .toggle-button.opened").click(); + return; + } + + if (text.indexOf("/expand") === 0) { + $(".chan.active .toggle-button:not(.opened)").click(); return; } @@ -899,7 +228,7 @@ $(function() { } $("button#set-nick").on("click", function() { - toggleNickEditor(true); + utils.toggleNickEditor(true); // Selects existing nick in the editable text field var element = document.querySelector("#nick-value"); @@ -914,11 +243,6 @@ $(function() { $("button#cancel-nick").on("click", cancelNick); $("button#submit-nick").on("click", submitNick); - function toggleNickEditor(toggle) { - $("#nick").toggleClass("editable", toggle); - $("#nick-value").attr("contenteditable", toggle); - } - function submitNick() { var newNick = $("#nick-value").text().trim(); @@ -927,7 +251,7 @@ $(function() { return; } - toggleNickEditor(false); + utils.toggleNickEditor(false); socket.emit("input", { target: chat.data("id"), @@ -936,7 +260,7 @@ $(function() { } function cancelNick() { - setNick(sidebar.find(".chan.active").closest(".network").data("nick")); + utils.setNick(sidebar.find(".chan.active").closest(".network").data("nick")); } $("#nick-value").keypress(function(e) { @@ -971,6 +295,10 @@ $(function() { } }); + chat.on("click", ".condensed-text", function() { + $(this).closest(".msg.condensed").toggleClass("closed"); + }); + chat.on("click", ".user", function() { var name = $(this).data("name"); var chan = findCurrentNetworkChan(name); @@ -985,6 +313,37 @@ $(function() { }); }); + sidebar.on("click", ".chan, button", function(e, data) { + // Pushes states to history web API when clicking elements with a data-target attribute. + // States are very trivial and only contain a single `clickTarget` property which + // contains a CSS selector that targets elements which takes the user to a different view + // when clicked. The `popstate` event listener will trigger synthetic click events using that + // selector and thus take the user to a different view/state. + if (data && data.pushState === false) { + return; + } + const self = $(this); + const target = self.data("target"); + if (!target) { + return; + } + const state = {}; + + if (self.hasClass("chan")) { + state.clickTarget = `.chan[data-id="${self.data("id")}"]`; + } else { + state.clickTarget = `#footer button[data-target="${target}"]`; + } + + if (history && history.pushState) { + if (data && data.replaceHistory && history.replaceState) { + history.replaceState(state, null, null); + } else { + history.pushState(state, null, null); + } + } + }); + sidebar.on("click", ".chan, button", function() { var self = $(this); var target = self.data("target"); @@ -1008,7 +367,7 @@ $(function() { .empty(); if (sidebar.find(".highlight").length === 0) { - toggleNotificationMarkers(false); + utils.toggleNotificationMarkers(false); } sidebarSlide.toggle(false); @@ -1032,7 +391,7 @@ $(function() { .addClass("active") .trigger("show"); - var title = "The Lounge"; + let title = $(document.body).data("app-name"); if (chan.data("title")) { title = chan.data("title") + " — " + title; } @@ -1046,11 +405,11 @@ $(function() { if (self.hasClass("chan")) { $("#chat-container").addClass("active"); - setNick(self.closest(".network").data("nick")); + utils.setNick(self.closest(".network").data("nick")); } var chanChat = chan.find(".chat"); - if (chanChat.length > 0) { + if (chanChat.length > 0 && chan.data("type") !== "special") { chanChat.sticky(); } @@ -1063,8 +422,12 @@ $(function() { }); sidebar.on("click", "#sign-out", function() { - window.localStorage.removeItem("token"); - location.reload(); + socket.emit("sign-out"); + storage.remove("token"); + + if (!socket.connected) { + location.reload(); + } }); sidebar.on("click", ".close", function() { @@ -1103,17 +466,31 @@ $(function() { }); chat.on("input", ".search", function() { - var value = $(this).val().toLowerCase(); - var names = $(this).closest(".users").find(".names"); - names.find(".user").each(function() { - var btn = $(this); - var name = btn.text().toLowerCase().replace(/[+%@~]/, ""); - if (name.indexOf(value) > -1) { - btn.show(); - } else { - btn.hide(); - } - }); + const value = $(this).val(); + const parent = $(this).closest(".users"); + const names = parent.find(".names-original"); + const container = parent.find(".names-filtered"); + + if (!value.length) { + container.hide(); + names.show(); + return; + } + + const fuzzyOptions = { + pre: "", + post: "", + extract: (el) => $(el).text() + }; + + const result = fuzzy.filter( + value, + names.find(".user").toArray(), + fuzzyOptions + ); + + names.hide(); + container.html(templates.user_filtered({matches: result})).show(); }); chat.on("msg", ".messages", function(e, target, msg) { @@ -1134,7 +511,7 @@ $(function() { // On mobile, sounds can not be played without user interaction. } } - toggleNotificationMarkers(true); + utils.toggleNotificationMarkers(true); if (options.desktopNotifications && Notification.permission === "granted") { var title; @@ -1168,7 +545,6 @@ $(function() { } catch (exception) { // `new Notification(...)` is not supported and should be silenced. } - } } } @@ -1190,32 +566,22 @@ $(function() { chat.on("click", ".show-more-button", function() { var self = $(this); - var count = self.parent().next(".messages").children().length; + var lastMessage = self.parent().next(".messages").children(".msg").first(); + if (lastMessage.is(".condensed")) { + lastMessage = lastMessage.children(".msg").first(); + } + var lastMessageId = parseInt(lastMessage[0].id.replace("msg-", ""), 10); + + self + .text("Loading older messages…") + .prop("disabled", true); + socket.emit("more", { target: self.data("id"), - count: count + lastId: lastMessageId }); }); - chat.on("click", ".toggle-button", function() { - var self = $(this); - var localChat = self.closest(".chat"); - var bottom = localChat.isScrollBottom(); - var content = self.parent().next(".toggle-content"); - if (bottom && !content.hasClass("show")) { - var img = content.find("img"); - if (img.length !== 0 && !img.width()) { - img.on("load", function() { - localChat.scrollBottom(); - }); - } - } - content.toggleClass("show"); - if (bottom) { - localChat.scrollBottom(); - } - }); - var forms = $("#sign-in, #connect, #change-password"); windows.on("show", "#sign-in", function() { @@ -1272,258 +638,179 @@ $(function() { } }); if (values.user) { - setLocalStorageItem("user", values.user); + storage.set("user", values.user); } socket.emit( event, values ); }); + forms.on("focusin", ".nick", function() { + // Need to set the first "lastvalue", so it can be used in the below function + var nick = $(this); + nick.data("lastvalue", nick.val()); + }); + forms.on("input", ".nick", function() { var nick = $(this).val(); - forms.find(".username").val(nick); - }); + var usernameInput = forms.find(".username"); - Mousetrap.bind([ - "command+up", - "command+down", - "ctrl+up", - "ctrl+down" - ], function(e, keys) { - var channels = sidebar.find(".chan"); - var index = channels.index(channels.filter(".active")); - var direction = keys.split("+").pop(); - switch (direction) { - case "up": - // Loop - var upTarget = (channels.length + (index - 1 + channels.length)) % channels.length; - channels.eq(upTarget).click(); - break; + // Because this gets called /after/ it has already changed, we need use the previous value + var lastValue = $(this).data("lastvalue"); - case "down": - // Loop - var downTarget = (channels.length + (index + 1 + channels.length)) % channels.length; - channels.eq(downTarget).click(); - break; + // They were the same before the change, so update the username field + if (usernameInput.val() === lastValue) { + usernameInput.val(nick); } + + // Store the "previous" value, for next time + $(this).data("lastvalue", nick); }); - Mousetrap.bind([ - "command+k", - "ctrl+shift+l" - ], function(e) { - if (e.target === input[0]) { - clear(); - e.preventDefault(); - } - }); + (function HotkeysScope() { + Mousetrap.bind([ + "pageup", + "pagedown" + ], function(e, key) { + let container = windows.find(".window.active"); - Mousetrap.bind([ - "escape" - ], function() { - contextMenuContainer.hide(); - }); + // Chat windows scroll message container + if (container.attr("id") === "chat-container") { + container = container.find(".chan.active .chat"); + } - setInterval(function() { - chat.find(".chan:not(.active)").each(function() { - var chan = $(this); - if (chan.find(".messages .msg").slice(0, -100).remove().length) { - chan.find(".show-more").addClass("show"); + container.finish(); - // Remove date-seperators that would otherwise be "stuck" at the top - // of the channel - chan.find(".date-marker").each(function() { - if ($(this).next().hasClass("date-marker")) { - $(this).remove(); - } - }); + const offset = container.get(0).clientHeight * 0.9; + let scrollTop = container.scrollTop(); + + if (key === "pageup") { + scrollTop = Math.floor(scrollTop - offset); + } else { + scrollTop = Math.ceil(scrollTop + offset); + } + + container.animate({ + scrollTop: scrollTop + }, 200); + + return false; + }); + + Mousetrap.bind([ + "command+up", + "command+down", + "ctrl+up", + "ctrl+down" + ], function(e, keys) { + var channels = sidebar.find(".chan"); + var index = channels.index(channels.filter(".active")); + var direction = keys.split("+").pop(); + switch (direction) { + case "up": + // Loop + var upTarget = (channels.length + (index - 1 + channels.length)) % channels.length; + channels.eq(upTarget).click(); + break; + + case "down": + // Loop + var downTarget = (channels.length + (index + 1 + channels.length)) % channels.length; + channels.eq(downTarget).click(); + break; } }); - }, 1000 * 10); - function clear() { - chat.find(".active") - .find(".show-more").addClass("show").end() - .find(".messages .msg, .date-marker").remove(); + Mousetrap.bind([ + "command+shift+l", + "ctrl+shift+l" + ], function(e) { + if (e.target === input[0]) { + utils.clear(); + e.preventDefault(); + } + }); + + Mousetrap.bind([ + "escape" + ], function() { + contextMenuContainer.hide(); + }); + + var colorsHotkeys = { + k: "\x03", + b: "\x02", + u: "\x1F", + i: "\x1D", + o: "\x0F", + }; + + for (var hotkey in colorsHotkeys) { + Mousetrap.bind([ + "command+" + hotkey, + "ctrl+" + hotkey + ], function(e) { + e.preventDefault(); + + const cursorPosStart = input.prop("selectionStart"); + const cursorPosEnd = input.prop("selectionEnd"); + const value = input.val(); + let newValue = value.substring(0, cursorPosStart) + colorsHotkeys[e.key]; + + if (cursorPosStart === cursorPosEnd) { + // If no text is selected, insert at cursor + newValue += value.substring(cursorPosEnd, value.length); + } else { + // If text is selected, insert formatting character at start and the end + newValue += value.substring(cursorPosStart, cursorPosEnd) + colorsHotkeys[e.key] + value.substring(cursorPosEnd, value.length); + } + + input + .val(newValue) + .get(0).setSelectionRange(cursorPosStart + 1, cursorPosEnd + 1); + }); + } + }()); + + $(document).on("visibilitychange focus click", () => { + if (sidebar.find(".highlight").length === 0) { + utils.toggleNotificationMarkers(false); + } + }); + + // Compute how many milliseconds are remaining until the next day starts + function msUntilNextDay() { + return moment().add(1, "day").startOf("day") - moment(); } - function complete(word) { - var words = commands.slice(); - var users = chat.find(".active").find(".users"); - var nicks = users.data("nicks"); - - for (var i in nicks) { - words.push(nicks[i]); - } - - sidebar.find(".chan") + // Go through all Today/Yesterday date markers in the DOM and recompute their + // labels. When done, restart the timer for the next day. + function updateDateMarkers() { + $(".date-marker-text[data-label='Today'], .date-marker-text[data-label='Yesterday']") + .closest(".date-marker-container") .each(function() { - var self = $(this); - if (!self.hasClass("lobby")) { - words.push(self.data("title")); - } + $(this).replaceWith(templates.date_marker({msgDate: $(this).data("timestamp")})); }); - return $.grep( - words, - function(w) { - return !w.toLowerCase().indexOf(word.toLowerCase()); - } - ); + // This should always be 24h later but re-computing exact value just in case + setTimeout(updateDateMarkers, msUntilNextDay()); } - - function confirmExit() { - if ($("body").hasClass("public")) { - window.onbeforeunload = function() { - return "Are you sure you want to navigate away from this page?"; - }; - } - } - - function sortable() { - 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() { - var order = []; - sidebar.find(".network").each(function() { - var id = $(this).data("id"); - order.push(id); - }); - socket.emit( - "sort", { - type: "networks", - order: order - } - ); - - 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) { - var order = []; - var network = ui.item.parent(); - network.find(".chan").each(function() { - var id = $(this).data("id"); - order.push(id); - }); - socket.emit( - "sort", { - type: "channels", - target: network.data("id"), - order: order - } - ); - - ignoreSortSync = true; - } - }); - } - - socket.on("sync_sort", function(data) { - // Syncs the order of channels or networks when they are reordered - if (ignoreSortSync) { - ignoreSortSync = false; - return; // Ignore syncing because we 'caused' it - } - - var type = data.type; - var order = data.order; - - if (type === "networks") { - var container = $(".networks"); - - $.each(order, function(index, value) { - var position = $(container.children()[index]); - - if (position.data("id") === value) { // Network in correct place - return true; // No point in continuing - } - - var network = container.find("#network-" + value); - - $(network).insertBefore(position); - }); - } else if (type === "channels") { - var 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 - } - - var position = $(network.children()[index]); // Target channel at position - - if (position.data("id") === value) { // Channel in correct place - return true; // No point in continuing - } - - var channel = network.find(".chan[data-id=" + value + "]"); // Channel at position - - $(channel).insertBefore(position); - }); - } - }); - - 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); - } - - function move(array, old_index, new_index) { - if (new_index >= array.length) { - var k = new_index - array.length; - while ((k--) + 1) { - this.push(undefined); - } - } - array.splice(new_index, 0, array.splice(old_index, 1)[0]); - return array; - } - - 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); - } - - document.addEventListener( - "visibilitychange", - function() { - if (sidebar.find(".highlight").length === 0) { - toggleNotificationMarkers(false); - } - } - ); + setTimeout(updateDateMarkers, msUntilNextDay()); // Only start opening socket.io connection after all events have been registered socket.open(); + + window.addEventListener("popstate", (e) => { + const {state} = e; + if (!state) { + return; + } + + const {clickTarget} = state; + if (clickTarget) { + $(clickTarget).trigger("click", { + pushState: false + }); + } + }); }); diff --git a/client/js/options.js b/client/js/options.js new file mode 100644 index 00000000..798da506 --- /dev/null +++ b/client/js/options.js @@ -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); +} diff --git a/client/js/render.js b/client/js/render.js new file mode 100644 index 00000000..03b73158 --- /dev/null +++ b/client/js/render.js @@ -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); + } +} diff --git a/client/js/renderPreview.js b/client/js/renderPreview.js new file mode 100644 index 00000000..14edb400 --- /dev/null +++ b/client/js/renderPreview.js @@ -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(); +} diff --git a/client/js/socket-events/auth.js b/client/js/socket-events/auth.js new file mode 100644 index 00000000..ad3539da --- /dev/null +++ b/client/js/socket-events/auth.js @@ -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(); +}); diff --git a/client/js/socket-events/change_password.js b/client/js/socket-events/change_password.js new file mode 100644 index 00000000..732aecd4 --- /dev/null +++ b/client/js/socket-events/change_password.js @@ -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); +}); diff --git a/client/js/socket-events/index.js b/client/js/socket-events/index.js new file mode 100644 index 00000000..23a5e961 --- /dev/null +++ b/client/js/socket-events/index.js @@ -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"); diff --git a/client/js/socket-events/init.js b/client/js/socket-events/init.js new file mode 100644 index 00000000..a78c07fa --- /dev/null +++ b/client/js/socket-events/init.js @@ -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, + }); + } + } +}); diff --git a/client/js/socket-events/join.js b/client/js/socket-events/join.js new file mode 100644 index 00000000..761a4622 --- /dev/null +++ b/client/js/socket-events/join.js @@ -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(); +}); diff --git a/client/js/socket-events/more.js b/client/js/socket-events/more.js new file mode 100644 index 00000000..6383a83a --- /dev/null +++ b/client/js/socket-events/more.js @@ -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); +}); diff --git a/client/js/socket-events/msg.js b/client/js/socket-events/msg.js new file mode 100644 index 00000000..f2b88498 --- /dev/null +++ b/client/js/socket-events/msg.js @@ -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(); + } + }); + } +} diff --git a/client/js/socket-events/msg_preview.js b/client/js/socket-events/msg_preview.js new file mode 100644 index 00000000..42972d61 --- /dev/null +++ b/client/js/socket-events/msg_preview.js @@ -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); +}); diff --git a/client/js/socket-events/names.js b/client/js/socket-events/names.js new file mode 100644 index 00000000..4d2036e8 --- /dev/null +++ b/client/js/socket-events/names.js @@ -0,0 +1,6 @@ +"use strict"; + +const socket = require("../socket"); +const render = require("../render"); + +socket.on("names", render.renderChannelUsers); diff --git a/client/js/socket-events/network.js b/client/js/socket-events/network.js new file mode 100644 index 00000000..1fb8036f --- /dev/null +++ b/client/js/socket-events/network.js @@ -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); +}); + diff --git a/client/js/socket-events/nick.js b/client/js/socket-events/nick.js new file mode 100644 index 00000000..75aa0411 --- /dev/null +++ b/client/js/socket-events/nick.js @@ -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); + } +}); diff --git a/client/js/socket-events/open.js b/client/js/socket-events/open.js new file mode 100644 index 00000000..8ed8ed3e --- /dev/null +++ b/client/js/socket-events/open.js @@ -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(); +}); diff --git a/client/js/socket-events/part.js b/client/js/socket-events/part.js new file mode 100644 index 00000000..e167653e --- /dev/null +++ b/client/js/socket-events/part.js @@ -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(); +}); diff --git a/client/js/socket-events/quit.js b/client/js/socket-events/quit.js new file mode 100644 index 00000000..dcf1b8bd --- /dev/null +++ b/client/js/socket-events/quit.js @@ -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(); + } +}); diff --git a/client/js/socket-events/sign_out.js b/client/js/socket-events/sign_out.js new file mode 100644 index 00000000..df16dbf5 --- /dev/null +++ b/client/js/socket-events/sign_out.js @@ -0,0 +1,9 @@ +"use strict"; + +const socket = require("../socket"); +const storage = require("../localStorage"); + +socket.on("sign-out", function() { + storage.remove("token"); + location.reload(); +}); diff --git a/client/js/socket-events/sync_sort.js b/client/js/socket-events/sync_sort.js new file mode 100644 index 00000000..2a344eca --- /dev/null +++ b/client/js/socket-events/sync_sort.js @@ -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); + }); + } +}); diff --git a/client/js/socket-events/topic.js b/client/js/socket-events/topic.js new file mode 100644 index 00000000..df16f263 --- /dev/null +++ b/client/js/socket-events/topic.js @@ -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); +}); diff --git a/client/js/socket-events/users.js b/client/js/socket-events/users.js new file mode 100644 index 00000000..a1b130d6 --- /dev/null +++ b/client/js/socket-events/users.js @@ -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); + } +}); diff --git a/client/js/socket.js b/client/js/socket.js new file mode 100644 index 00000000..6f702fb1 --- /dev/null +++ b/client/js/socket.js @@ -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; diff --git a/client/js/sorting.js b/client/js/sorting.js new file mode 100644 index 00000000..10ccc081 --- /dev/null +++ b/client/js/sorting.js @@ -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; + } + }); +}; diff --git a/client/js/utils.js b/client/js/utils.js new file mode 100644 index 00000000..00bb3416 --- /dev/null +++ b/client/js/utils.js @@ -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; +} diff --git a/client/js/webpush.js b/client/js/webpush.js new file mode 100644 index 00000000..87306f16 --- /dev/null +++ b/client/js/webpush.js @@ -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"; +} diff --git a/client/service-worker.js b/client/service-worker.js new file mode 100644 index 00000000..fcb63300 --- /dev/null +++ b/client/service-worker.js @@ -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("."); + } + })); +}); diff --git a/client/themes/crypto.css b/client/themes/crypto.css index 96df390f..454a7bd2 100644 --- a/client/themes/crypto.css +++ b/client/themes/crypto.css @@ -87,7 +87,7 @@ a:hover, 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%); } @@ -95,7 +95,7 @@ a:hover, #sidebar .chan, #sidebar .sign-out, #chat .time, -#chat .count:before, +#chat .count::before, #sidebar .empty { color: #666; } @@ -133,6 +133,12 @@ a:hover, color: #666; } -.tooltipped:after { +.tooltipped::after { font-family: Inconsolata-g, monospace; } + +/* Previews */ + +#chat .toggle-text { + line-height: initial; +} diff --git a/client/themes/example.css b/client/themes/example.css index 509c2a65..a8efcbf9 100644 --- a/client/themes/example.css +++ b/client/themes/example.css @@ -31,7 +31,7 @@ body { color: #ddd; } -#windows .window:before { +#windows .window::before { background: #f4f4f4; background-image: linear-gradient(#f4f4f4, #ececec); border-bottom: 1px solid #d7d7d7; diff --git a/client/themes/morning.css b/client/themes/morning.css index 49750ecd..9067c7f9 100644 --- a/client/themes/morning.css +++ b/client/themes/morning.css @@ -49,7 +49,7 @@ body { /* Borders */ #chat .from, #windows .header, -#chat .user-mode:before, +#chat .user-mode::before, #chat .sidebar { border-color: #2a323d; } @@ -64,10 +64,6 @@ body { color: #b0bacf; } -#chat .user:hover { - color: #fefefe; -} - #chat.colored-nicks .user.color-1 { color: #f7adf7; } #chat.colored-nicks .user.color-2 { color: #abf99f; } #chat.colored-nicks .user.color-3 { color: #86efdc; } @@ -105,10 +101,6 @@ body { color: #428bca; } -#chat button:hover { - opacity: 1; -} - /* Increase contrast of some IRC colors */ .irc-fg2 { color: #0074d9; } .irc-fg5 { color: #e969a7; } @@ -151,24 +143,19 @@ body { } /* Notification dot on the top right corner of the menu icon */ -#viewport .lt:after { +#viewport .lt::after { border-color: #333c4a; } -#chat .unread-marker, -#chat .date-marker { - opacity: 1; -} - -#chat .unread-marker-text:before { +#chat .unread-marker-text::before { background-color: #333c4a; } -#chat .date-marker:before { +#chat .date-marker::before { border-color: #97ea70; } -#chat .date-marker-text:before { +#chat .date-marker-text::before { background-color: #333c4a; color: #97ea70; } @@ -212,16 +199,14 @@ body { color: #84ce88 !important; } -/* Embeds */ -#chat .toggle-content, -#chat .toggle-button { - background: #242a33; - color: #f3f3f3; +#chat table.channel-list td { + color: #ccc; } -#chat .toggle-content img { - float: left; - margin-right: .5em; +/* Embeds */ +#chat .toggle-content { + background: #242a33; + color: #f3f3f3; } #chat .toggle-content .body { @@ -240,17 +225,23 @@ body { #chat-container ::-moz-placeholder { color: #99a2b4; - opacity: .5; + opacity: 0.5; } #chat-container ::-webkit-input-placeholder { color: #99a2b4; - opacity: .5; + opacity: 0.5; } #chat-container :-ms-input-placeholder { color: #99a2b4; - opacity: .5; + opacity: 0.5; } /* 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%); + } +} diff --git a/client/themes/zenburn.css b/client/themes/zenburn.css index a5969370..525110c7 100644 --- a/client/themes/zenburn.css +++ b/client/themes/zenburn.css @@ -54,12 +54,12 @@ body { 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%); } #footer { - background: #33332f; + background: #333; border-top: 1px solid #000; } @@ -75,7 +75,7 @@ body { /* Borders */ #chat .from, #windows .header, -#chat .user-mode:before, +#chat .user-mode::before, #chat .sidebar { border-color: #333; } @@ -90,10 +90,6 @@ body { color: #bc8cbc; } -#chat .user:hover { - color: #dcdccc; -} - #chat.colored-nicks .user.color-1 { color: #f7adf7; } #chat.colored-nicks .user.color-2 { color: #abf99f; } #chat.colored-nicks .user.color-3 { color: #86efdc; } @@ -131,10 +127,6 @@ body { color: #8c8cbc; } -#chat button:hover { - opacity: 1; -} - /* Increase contrast of some IRC colors */ .irc-fg2 { color: #1b94ff; } .irc-fg5 { color: #e969a7; } @@ -177,24 +169,19 @@ body { } /* Notification dot on the top right corner of the menu icon */ -#viewport .lt:after { +#viewport .lt::after { border-color: #3f3f3f; } -#chat .unread-marker, -.date-marker { - opacity: 1; -} - -#chat .unread-marker-text:before { +#chat .unread-marker-text::before { background-color: #3f3f3f; } -#chat .date-marker:before { +#chat .date-marker::before { border-color: #97ea70; } -#chat .date-marker-text:before { +#chat .date-marker-text::before { background-color: #3f3f3f; color: #97ea70; } @@ -238,16 +225,15 @@ body { color: #8cd0d3 !important; } -/* Embeds */ -#chat .toggle-content, -#chat .toggle-button { - background: #93b3a3; - color: #dcdccc; +#chat table.channel-list td { + color: #ccc; } -#chat .toggle-content img { - float: left; - margin-right: .5em; +/* Previews */ + +#chat .toggle-content { + background: #333; + color: #dcdccc; } #chat .toggle-content .body { @@ -266,17 +252,23 @@ body { #chat-container ::-moz-placeholder { color: #d2d39b; - opacity: .5; + opacity: 0.5; } #chat-container ::-webkit-input-placeholder { color: #d2d39b; - opacity: .5; + opacity: 0.5; } #chat-container :-ms-input-placeholder { color: #d2d39b; - opacity: .5; + opacity: 0.5; } /* 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%); + } +} diff --git a/client/views/actions/action.tpl b/client/views/actions/action.tpl index 3b850424..f11a0d35 100644 --- a/client/views/actions/action.tpl +++ b/client/views/actions/action.tpl @@ -1,2 +1,6 @@ -{{mode}}{{from}} -{{{parse text}}} +{{> ../user_name nick=from}} +{{{parse text}}} + +{{#each previews}} +
+{{/each}} diff --git a/client/views/actions/ban_list.tpl b/client/views/actions/ban_list.tpl new file mode 100644 index 00000000..c73ba7dc --- /dev/null +++ b/client/views/actions/ban_list.tpl @@ -0,0 +1,18 @@ + + + + + + + + + + {{#each bans}} + + + + + + {{/each}} + +
BannedBanned ByBanned At
{{hostmask}}{{{parse banned_by}}}{{{localetime banned_at}}}
diff --git a/client/views/actions/ctcp.tpl b/client/views/actions/ctcp.tpl index a34513b8..a64a296d 100644 --- a/client/views/actions/ctcp.tpl +++ b/client/views/actions/ctcp.tpl @@ -1,2 +1,2 @@ -{{from}} -{{ctcpType}} {{{parse ctcpMessage}}} +{{> ../user_name nick=from}} +{{ctcpType}} {{{parse ctcpMessage}}} diff --git a/client/views/actions/invite.tpl b/client/views/actions/invite.tpl index 3d2e9cfa..703f643d 100644 --- a/client/views/actions/invite.tpl +++ b/client/views/actions/invite.tpl @@ -1,9 +1,9 @@ -{{from}} +{{> ../user_name nick=from}} invited {{#if invitedYou}} you {{else}} - {{invited}} + {{> ../user_name nick=invited}} {{/if}} to {{{parse channel}}} diff --git a/client/views/actions/join.tpl b/client/views/actions/join.tpl index 431fa62e..d9749911 100644 --- a/client/views/actions/join.tpl +++ b/client/views/actions/join.tpl @@ -1,3 +1,3 @@ -{{mode}}{{from}} +{{> ../user_name nick=from}} ({{hostmask}}) has joined the channel diff --git a/client/views/actions/kick.tpl b/client/views/actions/kick.tpl index c66676d2..425a62b8 100644 --- a/client/views/actions/kick.tpl +++ b/client/views/actions/kick.tpl @@ -1,6 +1,6 @@ -{{mode}}{{from}} +{{> ../user_name nick=from}} has kicked -{{target}} +{{> ../user_name nick=target mode=""}} {{#if text}} ({{{parse text}}}) {{/if}} diff --git a/client/views/actions/mode.tpl b/client/views/actions/mode.tpl index ba6c38b3..dc439d5d 100644 --- a/client/views/actions/mode.tpl +++ b/client/views/actions/mode.tpl @@ -1,3 +1,3 @@ -{{mode}}{{from}} +{{> ../user_name nick=from}} sets mode {{{parse text}}} diff --git a/client/views/actions/nick.tpl b/client/views/actions/nick.tpl index 1c93cb97..2a292a3d 100644 --- a/client/views/actions/nick.tpl +++ b/client/views/actions/nick.tpl @@ -1,3 +1,3 @@ -{{mode}}{{from}} +{{> ../user_name nick=from}} is now known as -{{mode}}{{new_nick}} +{{> ../user_name nick=new_nick}} diff --git a/client/views/actions/part.tpl b/client/views/actions/part.tpl index 99a808c6..72d89cd0 100644 --- a/client/views/actions/part.tpl +++ b/client/views/actions/part.tpl @@ -1,4 +1,4 @@ -{{mode}}{{from}} +{{> ../user_name nick=from}} ({{hostmask}}) has left the channel {{#if text}} diff --git a/client/views/actions/quit.tpl b/client/views/actions/quit.tpl index ee58fab1..497739ca 100644 --- a/client/views/actions/quit.tpl +++ b/client/views/actions/quit.tpl @@ -1,4 +1,4 @@ -{{mode}}{{from}} +{{> ../user_name nick=from}} ({{hostmask}}) has quit {{#if text}} diff --git a/client/views/actions/topic.tpl b/client/views/actions/topic.tpl index 661297d3..88743be6 100644 --- a/client/views/actions/topic.tpl +++ b/client/views/actions/topic.tpl @@ -1,5 +1,5 @@ {{#if from}} - {{mode}}{{from}} + {{> ../user_name nick=from}} has changed the topic to: {{else}} The topic is: diff --git a/client/views/actions/topic_set_by.tpl b/client/views/actions/topic_set_by.tpl index e80c26a2..ac825fe3 100644 --- a/client/views/actions/topic_set_by.tpl +++ b/client/views/actions/topic_set_by.tpl @@ -1,3 +1,3 @@ Topic set by -{{mode}}{{nick}} +{{> ../user_name}} on {{localetime when}} diff --git a/client/views/actions/whois.tpl b/client/views/actions/whois.tpl index d5c9a250..c287429a 100644 --- a/client/views/actions/whois.tpl +++ b/client/views/actions/whois.tpl @@ -1,41 +1,41 @@
- {{whois.nick}} + {{> ../user_name nick=whois.nick}} ({{whois.user}}@{{whois.host}}): {{whois.real_name}}
{{#if whois.account}}
- {{whois.nick}} + {{> ../user_name nick=whois.nick}} is logged in as {{whois.account}}
{{/if}} {{#if whois.channels}}
- {{whois.nick}} + {{> ../user_name nick=whois.nick}} is on the following channels: {{{parse whois.channels}}}
{{/if}} {{#if whois.server}}
- {{whois.nick}} + {{> ../user_name nick=whois.nick}} is connected to {{whois.server}} ({{whois.server_info}})
{{/if}} {{#if whois.secure}}
- {{whois.nick}} + {{> ../user_name nick=whois.nick}} is using a secure connection
{{/if}} {{#if whois.away}}
- {{whois.nick}} + {{> ../user_name nick=whois.nick}} is away ({{whois.away}})
{{/if}} {{#if whois.idle}}
- {{whois.nick}} + {{> ../user_name nick=whois.nick}} has been idle since {{localetime whois.idleTime}}.
{{/if}} diff --git a/client/views/chan.tpl b/client/views/chan.tpl index 626eb228..d9e9d005 100644 --- a/client/views/chan.tpl +++ b/client/views/chan.tpl @@ -1,5 +1,5 @@ {{#each channels}} -
+
{{#if unread}}{{roundBadgeNumber unread}}{{/if}} {{name}} diff --git a/client/views/chat.tpl b/client/views/chat.tpl index 9ccb6391..35ebce8c 100644 --- a/client/views/chat.tpl +++ b/client/views/chat.tpl @@ -13,14 +13,20 @@
- +
+ {{#equal type "channel"}} + {{/equal}}
{{/each}} diff --git a/client/views/date-marker.tpl b/client/views/date-marker.tpl index bf9d89c7..9e67f09f 100644 --- a/client/views/date-marker.tpl +++ b/client/views/date-marker.tpl @@ -1,3 +1,5 @@ -
- +
+
+ +
diff --git a/client/views/image_viewer.tpl b/client/views/image_viewer.tpl new file mode 100644 index 00000000..aa6f8409 --- /dev/null +++ b/client/views/image_viewer.tpl @@ -0,0 +1,21 @@ + + +{{#if hasPreviousImage}} + +{{/if}} + +{{#if hasNextImage}} + +{{/if}} + + + Preview of {{link}} + + + + {{#equal type "image"}} + Open image + {{else}} + Visit page + {{/equal}} + diff --git a/client/views/index.js b/client/views/index.js index 2890dea9..aae3c78b 100644 --- a/client/views/index.js +++ b/client/views/index.js @@ -1,6 +1,9 @@ +"use strict"; + module.exports = { actions: { action: require("./actions/action.tpl"), + ban_list: require("./actions/ban_list.tpl"), channel_list: require("./actions/channel_list.tpl"), ctcp: require("./actions/ctcp.tpl"), invite: require("./actions/invite.tpl"), @@ -22,9 +25,15 @@ module.exports = { date_marker: require("./date-marker.tpl"), msg: require("./msg.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"), network: require("./network.tpl"), - toggle: require("./toggle.tpl"), + image_viewer: require("./image_viewer.tpl"), unread_marker: require("./unread_marker.tpl"), user: require("./user.tpl"), + user_filtered: require("./user_filtered.tpl"), + user_name: require("./user_name.tpl"), }; diff --git a/client/views/msg.tpl b/client/views/msg.tpl index 72811106..9b5b13f2 100644 --- a/client/views/msg.tpl +++ b/client/views/msg.tpl @@ -1,23 +1,17 @@ -
- +
+ {{tz time}} {{#if from}} - {{mode}}{{from}} + {{> user_name nick=from}} {{/if}} - {{#equal type "toggle"}} - -
- -
- {{#if toggle}} - {{> toggle}} - {{/if}} -
- {{else}} + {{{parse text}}} - {{/equal}} + + {{#each previews}} +
+ {{/each}}
diff --git a/client/views/msg_action.tpl b/client/views/msg_action.tpl index c2e9657c..42eb385f 100644 --- a/client/views/msg_action.tpl +++ b/client/views/msg_action.tpl @@ -1,7 +1,8 @@ -
- +
+ {{tz time}} - +
diff --git a/client/views/msg_condensed.tpl b/client/views/msg_condensed.tpl new file mode 100644 index 00000000..e2becd66 --- /dev/null +++ b/client/views/msg_condensed.tpl @@ -0,0 +1,7 @@ +
+ {{tz time}} + + + + +
diff --git a/client/views/msg_condensed_toggle.tpl b/client/views/msg_condensed_toggle.tpl new file mode 100644 index 00000000..2c740773 --- /dev/null +++ b/client/views/msg_condensed_toggle.tpl @@ -0,0 +1 @@ + diff --git a/client/views/msg_preview.tpl b/client/views/msg_preview.tpl new file mode 100644 index 00000000..aa20c363 --- /dev/null +++ b/client/views/msg_preview.tpl @@ -0,0 +1,19 @@ +{{#preview}} +
+ {{#equal type "image"}} + + + + {{else}} + {{#if thumb}} + + + + {{/if}} + +
{{head}}
+
{{body}}
+
+ {{/equal}} +
+{{/preview}} diff --git a/client/views/msg_preview_toggle.tpl b/client/views/msg_preview_toggle.tpl new file mode 100644 index 00000000..386282cc --- /dev/null +++ b/client/views/msg_preview_toggle.tpl @@ -0,0 +1,10 @@ +{{#preview}} + +{{/preview}} diff --git a/client/views/msg_unhandled.tpl b/client/views/msg_unhandled.tpl index 9c30f3d3..0316676e 100644 --- a/client/views/msg_unhandled.tpl +++ b/client/views/msg_unhandled.tpl @@ -1,9 +1,9 @@ -
- +
+ {{tz time}} [{{command}}] - + {{#each params}} {{this}} {{/each}} diff --git a/client/views/network.tpl b/client/views/network.tpl index 9da98019..05885d47 100644 --- a/client/views/network.tpl +++ b/client/views/network.tpl @@ -1,5 +1,5 @@ {{#each networks}} -
+
{{> chan}}
{{/each}} diff --git a/client/views/toggle.tpl b/client/views/toggle.tpl deleted file mode 100644 index 1c51c5f5..00000000 --- a/client/views/toggle.tpl +++ /dev/null @@ -1,19 +0,0 @@ -{{#toggle}} -
- {{#equal type "image"}} - - - - {{else}} - - {{#if thumb}} - - {{/if}} -
{{head}}
-
- {{body}} -
-
- {{/equal}} -
-{{/toggle}} diff --git a/client/views/user.tpl b/client/views/user.tpl index 23a3a9f0..7779601a 100644 --- a/client/views/user.tpl +++ b/client/views/user.tpl @@ -1,18 +1,11 @@ -{{#if users.length}} -
- -
-{{/if}} -
- {{#diff "reset"}}{{/diff}} - {{#each users}} - {{#diff mode}} - {{#unless @first}} -
- {{/unless}} -
- {{/diff}} - {{mode}}{{name}} - {{/each}} -
+{{#diff "reset"}}{{/diff}} +{{#each users}} + {{#diff mode}} + {{#unless @first}} +
+ {{/unless}} +
+ {{/diff}} + {{> user_name}} +{{/each}}
diff --git a/client/views/user_filtered.tpl b/client/views/user_filtered.tpl new file mode 100644 index 00000000..c684e3a2 --- /dev/null +++ b/client/views/user_filtered.tpl @@ -0,0 +1,5 @@ + diff --git a/client/views/user_name.tpl b/client/views/user_name.tpl new file mode 100644 index 00000000..e24850df --- /dev/null +++ b/client/views/user_name.tpl @@ -0,0 +1 @@ +{{mode}}{{nick}} diff --git a/defaults/config.js b/defaults/config.js index 742449f7..eeb02ea5 100644 --- a/defaults/config.js +++ b/defaults/config.js @@ -8,9 +8,9 @@ module.exports = { // Set to 'false' to enable users. // // @type boolean - // @default true + // @default false // - public: true, + public: false, // // IP address or hostname for the web server to listen on. @@ -66,11 +66,28 @@ module.exports = { // prefetch: false, + // + // Store and proxy prefetched images and thumbnails. + // This improves security and privacy by not exposing client IP address, + // and always loading images from The Lounge instance and making all assets secure, + // which in result fixes mixed content warnings. + // + // If storage is enabled, The Lounge will fetch and store images and thumbnails + // in the `${LOUNGE_HOME}/storage` folder. + // + // Images are deleted when they are no longer referenced by any message (controlled by maxHistory), + // and the folder is cleaned up on every The Lounge restart. + // + // @type boolean + // @default false + // + prefetchStorage: false, + // // Prefetch URLs Image Preview size limit // // If prefetch is enabled, The Lounge will only display content under the maximum size. - // Default value is 512 (in kB) + // Specified value is in kilobytes. Default value is 512 kilobytes. // // @type int // @default 512 @@ -287,9 +304,26 @@ module.exports = { // @example "sslcert/key-cert.pem" // @default "" // - certificate: "" + certificate: "", + + // + // Path to the CA bundle. + // + // @type string + // @example "sslcert/bundle.pem" + // @default "" + // + ca: "" }, + // + // Default quit and part message if none is provided. + // + // @type string + // @default "The Lounge - https://thelounge.github.io" + // + leaveMessage: "The Lounge - https://thelounge.github.io", + // // Run The Lounge with identd support. // diff --git a/package.json b/package.json index ce8a9b98..cabf7a65 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "thelounge", "description": "The self-hosted Web IRC client", - "version": "2.2.2", + "version": "2.4.0", "preferGlobal": true, "bin": { "lounge": "index.js" @@ -17,7 +17,7 @@ "start-dev": "npm-run-all --parallel watch start", "build": "npm-run-all build:*", "build:font-awesome": "node scripts/build-fontawesome.js", - "build:webpack": "webpack", + "build:webpack": "webpack --progress", "watch": "webpack --watch", "test": "npm-run-all -c test:* lint", "test:mocha": "mocha", @@ -40,41 +40,48 @@ "node": ">=4.2.0" }, "dependencies": { - "bcrypt-nodejs": "0.0.3", + "bcryptjs": "2.4.3", "cheerio": "0.22.0", "colors": "1.1.2", - "commander": "2.9.0", + "commander": "2.11.0", "event-stream": "3.3.4", - "express": "4.15.2", - "fs-extra": "2.0.0", - "irc-framework": "2.6.1", + "express": "4.15.4", + "express-handlebars": "3.0.0", + "fs-extra": "4.0.1", + "irc-framework": "2.9.1", "ldapjs": "1.0.1", "lodash": "4.17.4", - "moment": "2.17.1", + "moment": "2.18.1", "read": "1.0.7", "request": "2.81.0", - "semver": "5.3.0", - "socket.io": "1.7.3", - "spdy": "3.4.4" + "semver": "5.4.1", + "socket.io": "1.7.4", + "spdy": "3.4.7", + "ua-parser-js": "0.7.14", + "urijs": "1.18.12", + "web-push": "3.2.2" }, "devDependencies": { - "babel-core": "6.23.1", - "babel-loader": "6.4.0", - "babel-preset-es2015": "6.22.0", - "chai": "3.5.0", - "eslint": "3.17.1", + "babel-core": "6.26.0", + "babel-loader": "7.1.2", + "babel-preset-env": "1.6.0", + "chai": "4.1.1", + "emoji-regex": "6.5.1", + "eslint": "4.5.0", "font-awesome": "4.7.0", - "handlebars": "4.0.6", - "handlebars-loader": "1.4.0", - "jquery": "3.1.1", + "fuzzy": "0.1.3", + "handlebars": "4.0.10", + "handlebars-loader": "1.5.0", + "jquery": "3.2.1", + "jquery-textcomplete": "1.8.2", "jquery-ui": "1.12.1", - "mocha": "3.2.0", - "mousetrap": "1.6.0", - "npm-run-all": "4.0.2", - "nyc": "10.1.2", - "socket.io-client": "1.7.3", - "stylelint": "7.9.0", - "urijs": "1.18.9", - "webpack": "2.2.1" + "mocha": "3.5.0", + "mousetrap": "1.6.1", + "npm-run-all": "4.1.1", + "nyc": "11.1.0", + "socket.io-client": "1.7.4", + "stylelint": "8.0.0", + "stylelint-config-standard": "17.0.0", + "webpack": "3.5.5" } } diff --git a/scripts/noop.js b/scripts/noop.js new file mode 100644 index 00000000..bf1c83cb --- /dev/null +++ b/scripts/noop.js @@ -0,0 +1,5 @@ +"use strict"; + +module.exports = function() { + return function() {}; +}; diff --git a/scripts/run-pr.sh b/scripts/run-pr.sh index e8e65c5d..de020c75 100755 --- a/scripts/run-pr.sh +++ b/scripts/run-pr.sh @@ -9,7 +9,8 @@ fi git fetch https://github.com/thelounge/lounge.git refs/pull/${1}/head git checkout FETCH_HEAD +git rebase master npm install -npm run build +NODE_ENV=production npm run build npm test || true npm start diff --git a/src/client.js b/src/client.js index b7e1b3ef..ef3f5ce2 100644 --- a/src/client.js +++ b/src/client.js @@ -10,6 +10,7 @@ var Msg = require("./models/msg"); var Network = require("./models/network"); var ircFramework = require("irc-framework"); var Helper = require("./helper"); +const UAParser = require("ua-parser-js"); module.exports = Client; @@ -17,6 +18,7 @@ var id = 0; var events = [ "connection", "unhandled", + "banlist", "ctcp", "error", "invite", @@ -35,6 +37,7 @@ var events = [ "whois" ]; var inputs = [ + "ban", "ctcp", "msg", "part", @@ -56,7 +59,7 @@ var inputs = [ ].reduce(function(plugins, name) { var path = "./plugins/inputs/" + name; var plugin = require(path); - plugin.commands.forEach(command => plugins[command] = plugin); + plugin.commands.forEach((command) => plugins[command] = plugin); return plugins; }, {}); @@ -64,7 +67,9 @@ function Client(manager, name, config) { if (typeof config !== "object") { config = {}; } + _.merge(this, { + awayMessage: config.awayMessage || "", lastActiveChannel: -1, attachedClients: {}, config: config, @@ -77,25 +82,33 @@ function Client(manager, name, config) { var client = this; - if (client.name && !client.config.token) { - client.updateToken(function(token) { - client.manager.updateUser(client.name, {token: token}); - }); - } - var delay = 0; - (client.config.networks || []).forEach(n => { + (client.config.networks || []).forEach((n) => { setTimeout(function() { client.connect(n); }, delay); delay += 1000; }); + if (typeof client.config.sessions !== "object") { + client.config.sessions = {}; + } + + _.forOwn(client.config.sessions, (session) => { + if (session.pushSubscription) { + this.registerPushSubscription(session, session.pushSubscription, true); + } + }); + if (client.name) { log.info(`User ${colors.bold(client.name)} loaded`); } } +Client.prototype.isRegistered = function() { + return this.name.length > 0; +}; + Client.prototype.emit = function(event, data) { if (this.sockets !== null) { this.sockets.in(this.id).emit(event, data); @@ -151,14 +164,15 @@ Client.prototype.connect = function(args) { if (args.channels) { var badName = false; - args.channels.forEach(chan => { + args.channels.forEach((chan) => { if (!chan.name) { badName = true; return; } channels.push(new Chan({ - name: chan.name + name: chan.name, + key: chan.key || "", })); }); @@ -182,7 +196,7 @@ Client.prototype.connect = function(args) { args.hostname = args.hostname || (client.config && client.config.hostname) || client.hostname; var network = new Network({ - name: args.name || "", + name: args.name || (config.displayNetwork ? "" : config.defaults.name) || "", host: args.host || "", port: parseInt(args.port, 10) || (args.tls ? 6697 : 6667), tls: !!args.tls, @@ -261,7 +275,6 @@ Client.prototype.connect = function(args) { auto_reconnect: true, auto_reconnect_wait: 10000 + Math.floor(Math.random() * 1000), // If multiple users are connected to the same network, randomize their reconnections a little auto_reconnect_max_retries: 360, // At least one hour (plus timeouts) worth of reconnections - ping_interval: 0, // Disable client ping timeouts due to buggy implementation webirc: webirc, }); @@ -269,7 +282,7 @@ Client.prototype.connect = function(args) { "znc.in/self-message", // Legacy echo-message for ZNc ]); - events.forEach(plugin => { + events.forEach((plugin) => { var path = "./plugins/irc-events/" + plugin; require(path).apply(client, [ network.irc, @@ -282,40 +295,57 @@ Client.prototype.connect = function(args) { client.save(); }; -Client.prototype.updateToken = function(callback) { - var client = this; - - crypto.randomBytes(48, function(err, buf) { +Client.prototype.generateToken = function(callback) { + crypto.randomBytes(48, (err, buf) => { if (err) { throw err; } - callback(client.config.token = buf.toString("hex")); + callback(buf.toString("hex")); }); }; +Client.prototype.updateSession = function(token, ip, request) { + const client = this; + const agent = UAParser(request.headers["user-agent"] || ""); + let friendlyAgent = ""; + + if (agent.browser.name) { + friendlyAgent = `${agent.browser.name} ${agent.browser.major}`; + } else { + friendlyAgent = "Unknown browser"; + } + + if (agent.os.name) { + friendlyAgent += ` on ${agent.os.name} ${agent.os.version}`; + } + + client.config.sessions[token] = _.assign({ + lastUse: Date.now(), + ip: ip, + agent: friendlyAgent, + }, client.config.sessions[token]); +}; + Client.prototype.setPassword = function(hash, callback) { var client = this; - client.updateToken(function(token) { - client.manager.updateUser(client.name, { - token: token, - password: hash - }, function(err) { - if (err) { - log.error("Failed to update password of", client.name, err); - return callback(false); - } + client.manager.updateUser(client.name, { + password: hash + }, function(err) { + if (err) { + log.error("Failed to update password of", client.name, err); + return callback(false); + } - client.config.password = hash; - return callback(true); - }); + client.config.password = hash; + return callback(true); }); }; Client.prototype.input = function(data) { var client = this; - data.text.split("\n").forEach(line => { + data.text.split("\n").forEach((line) => { data.text = line; client.inputLine(data); }); @@ -373,14 +403,19 @@ Client.prototype.inputLine = function(data) { }; Client.prototype.more = function(data) { - var client = this; - var target = client.find(data.target); + const client = this; + const target = client.find(data.target); + if (!target) { return; } - var chan = target.chan; - var count = chan.messages.length - (data.count || 0); - var messages = chan.messages.slice(Math.max(0, count - 100), count); + + const chan = target.chan; + const index = chan.messages.findIndex((val) => val.id === data.lastId); + + // If we don't find the requested message, send an empty array + const messages = index > 0 ? chan.messages.slice(Math.max(0, index - 100), index) : []; + client.emit("more", { chan: chan.id, messages: messages @@ -390,7 +425,7 @@ Client.prototype.more = function(data) { Client.prototype.open = function(socketId, target) { // Opening a window like settings if (target === null) { - this.attachedClients[socketId] = -1; + this.attachedClients[socketId].openChannel = -1; return; } @@ -403,51 +438,43 @@ Client.prototype.open = function(socketId, target) { target.chan.unread = 0; target.chan.highlight = false; - this.attachedClients[socketId] = target.chan.id; + this.attachedClients[socketId].openChannel = target.chan.id; this.lastActiveChannel = target.chan.id; this.emit("open", target.chan.id); }; Client.prototype.sort = function(data) { - var self = this; + const order = data.order; - var type = data.type; - var order = data.order || []; - var sorted = []; + if (!_.isArray(order)) { + return; + } - switch (type) { + switch (data.type) { case "networks": - order.forEach(i => { - var find = _.find(self.networks, {id: i}); - if (find) { - sorted.push(find); - } - }); - self.networks = sorted; + this.networks.sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id)); + + // Sync order to connected clients + this.emit("sync_sort", {order: this.networks.map((obj) => obj.id), type: data.type, target: data.target}); + break; case "channels": - var target = data.target; - var network = _.find(self.networks, {id: target}); + var network = _.find(this.networks, {id: data.target}); if (!network) { return; } - order.forEach(i => { - var find = _.find(network.channels, {id: i}); - if (find) { - sorted.push(find); - } - }); - network.channels = sorted; + + network.channels.sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id)); + + // Sync order to connected clients + this.emit("sync_sort", {order: network.channels.map((obj) => obj.id), type: data.type, target: data.target}); + break; } - self.save(); - - // Sync order to connected clients - const syncOrder = sorted.map(obj => obj.id); - self.emit("sync_sort", {order: syncOrder, type: type, target: data.target}); + this.save(); }; Client.prototype.names = function(data) { @@ -472,21 +499,36 @@ Client.prototype.quit = function() { socket.disconnect(); } } - this.networks.forEach(network => { + this.networks.forEach((network) => { if (network.irc) { - network.irc.quit("Page closed"); + network.irc.quit(Helper.config.leaveMessage); } + + network.destroy(); }); }; -Client.prototype.clientAttach = function(socketId) { +Client.prototype.clientAttach = function(socketId, token) { var client = this; var save = false; - client.attachedClients[socketId] = client.lastActiveChannel; + if (client.awayMessage && _.size(client.attachedClients) === 0) { + client.networks.forEach(function(network) { + // Only remove away on client attachment if + // there is no away message on this network + if (!network.awayMessage) { + network.irc.raw("AWAY"); + } + }); + } + + client.attachedClients[socketId] = { + token: token, + openChannel: client.lastActiveChannel + }; // Update old networks to store ip and hostmask - client.networks.forEach(network => { + client.networks.forEach((network) => { if (!network.ip) { save = true; network.ip = (client.config && client.config.ip) || client.ip; @@ -508,7 +550,53 @@ Client.prototype.clientAttach = function(socketId) { }; Client.prototype.clientDetach = function(socketId) { + const client = this; + delete this.attachedClients[socketId]; + + if (client.awayMessage && _.size(client.attachedClients) === 0) { + client.networks.forEach(function(network) { + // Only set away on client deattachment if + // there is no away message on this network + if (!network.awayMessage) { + network.irc.raw("AWAY", client.awayMessage); + } + }); + } +}; + +Client.prototype.registerPushSubscription = function(session, subscription, noSave) { + if (!_.isPlainObject(subscription) || !_.isPlainObject(subscription.keys) + || typeof subscription.endpoint !== "string" || !/^https?:\/\//.test(subscription.endpoint) + || typeof subscription.keys.p256dh !== "string" || typeof subscription.keys.auth !== "string") { + session.pushSubscription = null; + return; + } + + const data = { + endpoint: subscription.endpoint, + keys: { + p256dh: subscription.keys.p256dh, + auth: subscription.keys.auth + } + }; + + session.pushSubscription = data; + + if (!noSave) { + this.manager.updateUser(this.name, { + sessions: this.config.sessions + }); + } + + return data; +}; + +Client.prototype.unregisterPushSubscription = function(token) { + this.config.sessions[token].pushSubscription = null; + this.manager.updateUser(this.name, { + sessions: this.config.sessions + }); }; Client.prototype.save = _.debounce(function SaveClient() { @@ -517,7 +605,7 @@ Client.prototype.save = _.debounce(function SaveClient() { } const client = this; - let json = {}; - json.networks = this.networks.map(n => n.export()); + const json = {}; + json.networks = this.networks.map((n) => n.export()); client.manager.updateUser(client.name, json); }, 1000, {maxWait: 10000}); diff --git a/src/clientManager.js b/src/clientManager.js index 578972c3..6211fb46 100644 --- a/src/clientManager.js +++ b/src/clientManager.js @@ -5,6 +5,7 @@ var colors = require("colors/safe"); var fs = require("fs"); var Client = require("./client"); var Helper = require("./helper"); +const WebPush = require("./plugins/webpush"); module.exports = ClientManager; @@ -15,8 +16,9 @@ function ClientManager() { ClientManager.prototype.init = function(identHandler, sockets) { this.sockets = sockets; this.identHandler = identHandler; + this.webPush = new WebPush(); - if (!Helper.config.public) { + if (!Helper.config.public && !Helper.config.ldap.enable) { if ("autoload" in Helper.config) { log.warn(`Autoloading users is now always enabled. Please remove the ${colors.yellow("autoload")} option from your configuration file.`); } @@ -25,28 +27,39 @@ ClientManager.prototype.init = function(identHandler, sockets) { } }; -ClientManager.prototype.findClient = function(name, token) { - for (var i in this.clients) { - var client = this.clients[i]; - if (client.name === name || (token && token === client.config.token)) { - return client; - } - } - return false; +ClientManager.prototype.findClient = function(name) { + return this.clients.find((u) => u.name === name); }; ClientManager.prototype.autoloadUsers = function() { - this.getUsers().forEach(name => this.loadUser(name)); + const users = this.getUsers(); + const noUsersWarning = `There are currently no users. Create one with ${colors.bold("lounge add ")}.`; + + // There was an error, already logged, but we have to crash the server as + // user directory could not be accessed + if (users === undefined) { + process.exit(1); + } + + if (users.length === 0) { + log.info(noUsersWarning); + } + + users.forEach((name) => this.loadUser(name)); fs.watch(Helper.USERS_PATH, _.debounce(() => { - const loaded = this.clients.map(c => c.name); + const loaded = this.clients.map((c) => c.name); const updatedUsers = this.getUsers(); + if (updatedUsers.length === 0) { + log.info(noUsersWarning); + } + // New users created since last time users were loaded - _.difference(updatedUsers, loaded).forEach(name => this.loadUser(name)); + _.difference(updatedUsers, loaded).forEach((name) => this.loadUser(name)); // Existing users removed since last time users were loaded - _.difference(loaded, updatedUsers).forEach(name => { + _.difference(loaded, updatedUsers).forEach((name) => { const client = _.find(this.clients, {name: name}); if (client) { client.quit(); @@ -78,13 +91,13 @@ ClientManager.prototype.getUsers = function() { var users = []; try { var files = fs.readdirSync(Helper.USERS_PATH); - files.forEach(file => { + files.forEach((file) => { if (file.indexOf(".json") !== -1) { users.push(file.replace(".json", "")); } }); } catch (e) { - log.error("Failed to get users", e); + log.error(`Failed to get users (${e})`); return; } return users; @@ -96,7 +109,6 @@ ClientManager.prototype.addUser = function(name, password, enableLog) { return false; } try { - if (require("path").basename(name) !== name) { throw new Error(name + " is an invalid username."); } @@ -105,7 +117,9 @@ ClientManager.prototype.addUser = function(name, password, enableLog) { user: name, password: password || "", log: enableLog, - networks: [] + awayMessage: "", + networks: [], + sessions: {}, }; fs.writeFileSync( Helper.getUserConfigPath(name), @@ -127,7 +141,7 @@ ClientManager.prototype.updateUser = function(name, opts, callback) { return false; } - let user = this.readUserConfig(name); + const user = this.readUserConfig(name); const currentUser = JSON.stringify(user, null, "\t"); _.assign(user, opts); const newUser = JSON.stringify(user, null, "\t"); diff --git a/src/command-line/add.js b/src/command-line/add.js index 7c0214c5..894e5f88 100644 --- a/src/command-line/add.js +++ b/src/command-line/add.js @@ -1,20 +1,39 @@ "use strict"; -var ClientManager = new require("../clientManager"); -var colors = require("colors/safe"); -var program = require("commander"); -var Helper = require("../helper"); +const colors = require("colors/safe"); +const program = require("commander"); +const fs = require("fs"); +const Helper = require("../helper"); +const Utils = require("./utils"); program .command("add ") .description("Add a new user") + .on("--help", Utils.extraHelp) .action(function(name) { - var manager = new ClientManager(); - var users = manager.getUsers(); + if (!fs.existsSync(Helper.USERS_PATH)) { + log.error(`${Helper.USERS_PATH} does not exist.`); + return; + } + + const ClientManager = require("../clientManager"); + + if (Helper.config.public) { + log.warn(`Users have no effect in ${colors.bold("public")} mode.`); + } + + const manager = new ClientManager(); + const users = manager.getUsers(); + + if (users === undefined) { // There was an error, already logged + return; + } + if (users.indexOf(name) !== -1) { log.error(`User ${colors.bold(name)} already exists.`); return; } + log.prompt({ text: "Enter password:", silent: true diff --git a/src/command-line/config.js b/src/command-line/config.js index 1eae72f2..9b601dc8 100644 --- a/src/command-line/config.js +++ b/src/command-line/config.js @@ -1,14 +1,22 @@ "use strict"; -var program = require("commander"); -var child = require("child_process"); -var colors = require("colors/safe"); -var Helper = require("../helper"); +const program = require("commander"); +const child = require("child_process"); +const colors = require("colors/safe"); +const fs = require("fs"); +const Helper = require("../helper"); +const Utils = require("./utils"); program .command("config") .description(`Edit configuration file located at ${colors.green(Helper.CONFIG_PATH)}.`) + .on("--help", Utils.extraHelp) .action(function() { + if (!fs.existsSync(Helper.CONFIG_PATH)) { + log.error(`${Helper.CONFIG_PATH} does not exist.`); + return; + } + var child_spawn = child.spawn( process.env.EDITOR || "vi", [Helper.CONFIG_PATH], diff --git a/src/command-line/edit.js b/src/command-line/edit.js index f33f9e1c..2b552f6b 100644 --- a/src/command-line/edit.js +++ b/src/command-line/edit.js @@ -1,16 +1,30 @@ "use strict"; -var ClientManager = new require("../clientManager"); -var program = require("commander"); -var child = require("child_process"); -var colors = require("colors/safe"); -var Helper = require("../helper"); +const program = require("commander"); +const child = require("child_process"); +const colors = require("colors/safe"); +const fs = require("fs"); +const Helper = require("../helper"); +const Utils = require("./utils"); program .command("edit ") .description(`Edit user file located at ${colors.green(Helper.getUserConfigPath(""))}.`) + .on("--help", Utils.extraHelp) .action(function(name) { + if (!fs.existsSync(Helper.USERS_PATH)) { + log.error(`${Helper.USERS_PATH} does not exist.`); + return; + } + + const ClientManager = require("../clientManager"); + var users = new ClientManager().getUsers(); + + if (users === undefined) { // There was an error, already logged + return; + } + if (users.indexOf(name) === -1) { log.error(`User ${colors.bold(name)} does not exist.`); return; diff --git a/src/command-line/index.js b/src/command-line/index.js index 36633c28..285d5fe2 100644 --- a/src/command-line/index.js +++ b/src/command-line/index.js @@ -2,33 +2,28 @@ global.log = require("../log.js"); -var program = require("commander"); -var colors = require("colors/safe"); -var fs = require("fs"); -var fsextra = require("fs-extra"); -var path = require("path"); -var Helper = require("../helper"); +const program = require("commander"); +const colors = require("colors/safe"); +const Helper = require("../helper"); +const Utils = require("./utils"); program.version(Helper.getVersion(), "-v, --version") - .option("--home ", "path to configuration folder") + .option("--home ", `${colors.bold("[DEPRECATED]")} Use the ${colors.green("LOUNGE_HOME")} environment variable instead.`) + .on("--help", Utils.extraHelp) .parseOptions(process.argv); -Helper.setHome(program.home || process.env.LOUNGE_HOME); - -if (!fs.existsSync(Helper.CONFIG_PATH)) { - fsextra.ensureDirSync(Helper.HOME); - fs.chmodSync(Helper.HOME, "0700"); - fsextra.copySync(path.resolve(path.join( - __dirname, - "..", - "..", - "defaults", - "config.js" - )), Helper.CONFIG_PATH); - log.info(`Configuration file created at ${colors.green(Helper.CONFIG_PATH)}.`); +if (program.home) { + log.warn(`${colors.green("--home")} is ${colors.bold("deprecated")} and will be removed in a future version.`); + log.warn(`Use the ${colors.green("LOUNGE_HOME")} environment variable instead.`); } -fsextra.ensureDirSync(Helper.USERS_PATH); +let home = program.home || process.env.LOUNGE_HOME; + +if (!home) { + home = Utils.defaultLoungeHome(); +} + +Helper.setHome(home); require("./start"); require("./config"); diff --git a/src/command-line/list.js b/src/command-line/list.js index 8020edda..d092ad8b 100644 --- a/src/command-line/list.js +++ b/src/command-line/list.js @@ -1,20 +1,39 @@ "use strict"; -var ClientManager = new require("../clientManager"); -var program = require("commander"); -var colors = require("colors/safe"); +const colors = require("colors/safe"); +const program = require("commander"); +const fs = require("fs"); +const Helper = require("../helper"); +const Utils = require("./utils"); program .command("list") .description("List all users") + .on("--help", Utils.extraHelp) .action(function() { + if (!fs.existsSync(Helper.USERS_PATH)) { + log.error(`${Helper.USERS_PATH} does not exist.`); + return; + } + + const ClientManager = require("../clientManager"); + + if (Helper.config.public) { + log.warn(`Users have no effect in ${colors.bold("public")} mode.`); + } + var users = new ClientManager().getUsers(); - if (!users.length) { - log.warn("No users found."); - } else { + + if (users === undefined) { // There was an error, already logged + return; + } + + if (users.length > 0) { log.info("Users:"); - for (var i = 0; i < users.length; i++) { - log.info(`${i + 1}. ${colors.bold(users[i])}`); - } + users.forEach((user, i) => { + log.info(`${i + 1}. ${colors.bold(user)}`); + }); + } else { + log.info(`There are currently no users. Create one with ${colors.bold("lounge add ")}.`); } }); diff --git a/src/command-line/remove.js b/src/command-line/remove.js index 3e90b658..beb3f3b1 100644 --- a/src/command-line/remove.js +++ b/src/command-line/remove.js @@ -1,17 +1,31 @@ "use strict"; -var ClientManager = new require("../clientManager"); -var program = require("commander"); -var colors = require("colors/safe"); +const colors = require("colors/safe"); +const program = require("commander"); +const fs = require("fs"); +const Helper = require("../helper"); +const Utils = require("./utils"); program .command("remove ") .description("Remove an existing user") + .on("--help", Utils.extraHelp) .action(function(name) { - var manager = new ClientManager(); - if (manager.removeUser(name)) { - log.info(`User ${colors.bold(name)} removed.`); - } else { - log.error(`User ${colors.bold(name)} does not exist.`); + if (!fs.existsSync(Helper.USERS_PATH)) { + log.error(`${Helper.USERS_PATH} does not exist.`); + return; + } + + const ClientManager = require("../clientManager"); + const manager = new ClientManager(); + + try { + if (manager.removeUser(name)) { + log.info(`User ${colors.bold(name)} removed.`); + } else { + log.error(`User ${colors.bold(name)} does not exist.`); + } + } catch (e) { + // There was an error, already logged } }); diff --git a/src/command-line/reset.js b/src/command-line/reset.js index 8be036e7..a5f4da43 100644 --- a/src/command-line/reset.js +++ b/src/command-line/reset.js @@ -1,16 +1,29 @@ "use strict"; -var ClientManager = new require("../clientManager"); -var fs = require("fs"); -var program = require("commander"); -var colors = require("colors/safe"); -var Helper = require("../helper"); +const colors = require("colors/safe"); +const program = require("commander"); +const fs = require("fs"); +const Helper = require("../helper"); +const Utils = require("./utils"); program .command("reset ") .description("Reset user password") + .on("--help", Utils.extraHelp) .action(function(name) { + if (!fs.existsSync(Helper.USERS_PATH)) { + log.error(`${Helper.USERS_PATH} does not exist.`); + return; + } + + const ClientManager = require("../clientManager"); + var users = new ClientManager().getUsers(); + + if (users === undefined) { // There was an error, already logged + return; + } + if (users.indexOf(name) === -1) { log.error(`User ${colors.bold(name)} does not exist.`); return; @@ -25,7 +38,7 @@ program return; } user.password = Helper.password.hash(password); - user.token = null; // Will be regenerated when the user is loaded + user.sessions = {}; fs.writeFileSync( file, JSON.stringify(user, null, "\t") diff --git a/src/command-line/start.js b/src/command-line/start.js index f1c6ca10..fbec3f17 100644 --- a/src/command-line/start.js +++ b/src/command-line/start.js @@ -1,10 +1,12 @@ "use strict"; -var ClientManager = new require("../clientManager"); -var program = require("commander"); -var colors = require("colors/safe"); -var server = require("../server"); -var Helper = require("../helper"); +const colors = require("colors/safe"); +const fs = require("fs"); +const fsextra = require("fs-extra"); +const path = require("path"); +const program = require("commander"); +const Helper = require("../helper"); +const Utils = require("./utils"); program .command("start") @@ -14,8 +16,11 @@ program .option(" --public", "start in public mode") .option(" --private", "start in private mode") .description("Start the server") + .on("--help", Utils.extraHelp) .action(function(options) { - var users = new ClientManager().getUsers(); + initalizeConfig(); + + const server = require("../server"); var mode = Helper.config.public; if (options.public) { @@ -24,13 +29,6 @@ program mode = false; } - if (!mode && !users.length && !Helper.config.ldap.enable) { - log.warn("No users found."); - log.info(`Create a new user with ${colors.bold("lounge add ")}.`); - - return; - } - Helper.config.host = options.host || Helper.config.host; Helper.config.port = options.port || Helper.config.port; Helper.config.bind = options.bind || Helper.config.bind; @@ -38,3 +36,20 @@ program server(); }); + +function initalizeConfig() { + if (!fs.existsSync(Helper.CONFIG_PATH)) { + fsextra.ensureDirSync(Helper.HOME); + fs.chmodSync(Helper.HOME, "0700"); + fsextra.copySync(path.resolve(path.join( + __dirname, + "..", + "..", + "defaults", + "config.js" + )), Helper.CONFIG_PATH); + log.info(`Configuration file created at ${colors.green(Helper.CONFIG_PATH)}.`); + } + + fsextra.ensureDirSync(Helper.USERS_PATH); +} diff --git a/src/command-line/utils.js b/src/command-line/utils.js new file mode 100644 index 00000000..8f5676af --- /dev/null +++ b/src/command-line/utils.js @@ -0,0 +1,38 @@ +"use strict"; + +const colors = require("colors/safe"); +const fs = require("fs"); +const path = require("path"); + +let loungeHome; + +class Utils { + static extraHelp() { + [ + "", + "", + " Environment variable:", + "", + ` LOUNGE_HOME Path for all configuration files and folders. Defaults to ${colors.green(Utils.defaultLoungeHome())}.`, + "", + ].forEach((e) => console.log(e)); + } + + static defaultLoungeHome() { + if (loungeHome) { + return loungeHome; + } + const distConfig = path.resolve(path.join( + __dirname, + "..", + "..", + ".lounge_home" + )); + + loungeHome = fs.readFileSync(distConfig, "utf-8").trim(); + + return loungeHome; + } +} + +module.exports = Utils; diff --git a/src/helper.js b/src/helper.js index c6e971c1..288dcfec 100644 --- a/src/helper.js +++ b/src/helper.js @@ -6,17 +6,20 @@ var path = require("path"); var os = require("os"); var fs = require("fs"); var net = require("net"); -var bcrypt = require("bcrypt-nodejs"); +var bcrypt = require("bcryptjs"); +const colors = require("colors/safe"); var Helper = { config: null, expandHome: expandHome, + getStoragePath: getStoragePath, getUserConfigPath: getUserConfigPath, getUserLogsPath: getUserLogsPath, setHome: setHome, getVersion: getVersion, getGitCommit: getGitCommit, ip2hex: ip2hex, + cleanIrcMessage: cleanIrcMessage, password: { hash: passwordHash, @@ -58,14 +61,20 @@ function getGitCommit() { } function setHome(homePath) { - this.HOME = expandHome(homePath || "~/.lounge"); + this.HOME = expandHome(homePath); this.CONFIG_PATH = path.join(this.HOME, "config.js"); this.USERS_PATH = path.join(this.HOME, "users"); // Reload config from new home location if (fs.existsSync(this.CONFIG_PATH)) { var userConfig = require(this.CONFIG_PATH); - this.config = _.extend(this.config, userConfig); + this.config = _.merge(this.config, userConfig); + } + + if (!this.config.displayNetwork && !this.config.lockNetwork) { + this.config.lockNetwork = true; + + log.warn(`${colors.bold("displayNetwork")} and ${colors.bold("lockNetwork")} are false, setting ${colors.bold("lockNetwork")} to true.`); } // TODO: Remove in future release @@ -83,6 +92,10 @@ function getUserLogsPath(name, network) { return path.join(this.HOME, "logs", name, network); } +function getStoragePath() { + return path.join(this.HOME, "storage"); +} + function ip2hex(address) { // no ipv6 support if (!net.isIPv4(address)) { @@ -101,21 +114,19 @@ function ip2hex(address) { } function expandHome(shortenedPath) { - var home; - - if (os.homedir) { - home = os.homedir(); + if (!shortenedPath) { + return ""; } - if (!home) { - home = process.env.HOME || ""; - } - - home = home.replace("$", "$$$$"); - + const home = os.homedir().replace("$", "$$$$"); return path.resolve(shortenedPath.replace(/^~($|\/|\\)/, home + "$1")); } +function cleanIrcMessage(message) { + // TODO: This does not strip hex based colours + return message.replace(/\x02|\x1D|\x1F|\x16|\x0F|\x03(?:[0-9]{1,2}(?:,[0-9]{1,2})?)?/g, ""); +} + function passwordRequiresUpdate(password) { return bcrypt.getRounds(password) !== 11; } @@ -125,5 +136,5 @@ function passwordHash(password) { } function passwordCompare(password, expected) { - return bcrypt.compareSync(password, expected); + return bcrypt.compare(password, expected); } diff --git a/src/identification.js b/src/identification.js index ea4cca28..8ea51fe7 100644 --- a/src/identification.js +++ b/src/identification.js @@ -38,7 +38,7 @@ class Identification { } serverConnection(socket) { - socket.on("data", data => { + socket.on("data", (data) => { this.respondToIdent(socket, data); socket.end(); }); diff --git a/src/models/chan.js b/src/models/chan.js index 69e582ab..e06e264f 100644 --- a/src/models/chan.js +++ b/src/models/chan.js @@ -2,6 +2,7 @@ var _ = require("lodash"); var Helper = require("../helper"); +const storage = require("../plugins/storage"); module.exports = Chan; @@ -19,6 +20,7 @@ function Chan(attr) { id: id++, messages: [], name: "", + key: "", topic: "", type: Chan.Type.CHANNEL, firstUnread: 0, @@ -28,6 +30,10 @@ function Chan(attr) { }); } +Chan.prototype.destroy = function() { + this.dereferencePreviews(this.messages); +}; + Chan.prototype.pushMessage = function(client, msg, increasesUnread) { var obj = { chan: this.id, @@ -35,7 +41,7 @@ Chan.prototype.pushMessage = function(client, msg, increasesUnread) { }; // If this channel is open in any of the clients, do not increase unread counter - var isOpen = _.includes(client.attachedClients, this.id); + const isOpen = _.find(client.attachedClients, {openChannel: this.id}) !== undefined; if ((increasesUnread || msg.highlight) && !isOpen) { obj.unread = ++this.unread; @@ -52,7 +58,13 @@ Chan.prototype.pushMessage = function(client, msg, increasesUnread) { this.messages.push(msg); if (Helper.config.maxHistory >= 0 && this.messages.length > Helper.config.maxHistory) { - this.messages.splice(0, this.messages.length - Helper.config.maxHistory); + const deleted = this.messages.splice(0, this.messages.length - Helper.config.maxHistory); + + // If maxHistory is 0, image would be dereferenced before client had a chance to retrieve it, + // so for now, just don't implement dereferencing for this edge case. + if (Helper.config.prefetch && Helper.config.prefetchStorage && Helper.config.maxHistory > 0) { + this.dereferencePreviews(deleted); + } } if (!msg.self && !isOpen) { @@ -66,6 +78,15 @@ Chan.prototype.pushMessage = function(client, msg, increasesUnread) { } }; +Chan.prototype.dereferencePreviews = function(messages) { + messages.forEach((message) => { + if (message.preview && message.preview.thumb) { + storage.dereference(message.preview.thumb); + message.preview.thumb = null; + } + }); +}; + Chan.prototype.sortUsers = function(irc) { var userModeSortPriority = {}; irc.network.options.PREFIX.forEach((prefix, index) => { @@ -76,15 +97,23 @@ Chan.prototype.sortUsers = function(irc) { this.users = this.users.sort(function(a, b) { if (a.mode === b.mode) { - return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1; + return a.nick.toLowerCase() < b.nick.toLowerCase() ? -1 : 1; } return userModeSortPriority[a.mode] - userModeSortPriority[b.mode]; }); }; +Chan.prototype.findMessage = function(msgId) { + return this.messages.find((message) => message.id === msgId); +}; + +Chan.prototype.findUser = function(nick) { + return _.find(this.users, {nick: nick}); +}; + Chan.prototype.getMode = function(name) { - var user = _.find(this.users, {name: name}); + var user = this.findUser(name); if (user) { return user.mode; } @@ -94,6 +123,7 @@ Chan.prototype.getMode = function(name) { Chan.prototype.toJSON = function() { var clone = _.clone(this); + clone.users = []; // Do not send user list, the client will explicitly request it when needed clone.messages = clone.messages.slice(-100); return clone; }; diff --git a/src/models/msg.js b/src/models/msg.js index a3ce4bc8..019a7198 100644 --- a/src/models/msg.js +++ b/src/models/msg.js @@ -2,6 +2,31 @@ var _ = require("lodash"); +var id = 0; + +class Msg { + constructor(attr) { + _.defaults(this, attr, { + from: "", + id: id++, + previews: [], + text: "", + type: Msg.Type.MESSAGE, + self: false + }); + + if (this.time > 0) { + this.time = new Date(this.time); + } else { + this.time = new Date(); + } + } + + findPreview(link) { + return this.previews.find((preview) => preview.link === link); + } +} + Msg.Type = { UNHANDLED: "unhandled", ACTION: "action", @@ -16,29 +41,11 @@ Msg.Type = { NOTICE: "notice", PART: "part", QUIT: "quit", - TOGGLE: "toggle", CTCP: "ctcp", TOPIC: "topic", TOPIC_SET_BY: "topic_set_by", - WHOIS: "whois" + WHOIS: "whois", + BANLIST: "ban_list" }; module.exports = Msg; - -var id = 0; - -function Msg(attr) { - _.defaults(this, attr, { - from: "", - id: id++, - text: "", - type: Msg.Type.MESSAGE, - self: false - }); - - if (this.time > 0) { - this.time = new Date(this.time); - } else { - this.time = new Date(); - } -} diff --git a/src/models/network.js b/src/models/network.js index 0ddd3530..71a7dd7d 100644 --- a/src/models/network.js +++ b/src/models/network.js @@ -14,6 +14,7 @@ function Network(attr) { port: 6667, tls: false, password: "", + awayMessage: "", commands: [], username: "", realname: "", @@ -24,11 +25,15 @@ function Network(attr) { irc: null, serverOptions: { PREFIX: [], + NETWORK: "", }, chanCache: [], }); - this.name = attr.name || prettify(attr.host); + if (!this.name) { + this.name = this.host; + } + this.channels.unshift( new Chan({ name: this.name, @@ -37,6 +42,10 @@ function Network(attr) { ); } +Network.prototype.destroy = function() { + this.channels.forEach((channel) => channel.destroy()); +}; + Network.prototype.setNick = function(nick) { this.nick = nick; this.highlightRegex = new RegExp( @@ -56,6 +65,7 @@ Network.prototype.setNick = function(nick) { Network.prototype.toJSON = function() { return _.omit(this, [ + "awayMessage", "chanCache", "highlightRegex", "irc", @@ -65,6 +75,7 @@ Network.prototype.toJSON = function() { Network.prototype.export = function() { var network = _.pick(this, [ + "awayMessage", "nick", "name", "host", @@ -84,7 +95,8 @@ Network.prototype.export = function() { }) .map(function(chan) { return _.pick(chan, [ - "name" + "name", + "key", ]); }); @@ -98,17 +110,3 @@ Network.prototype.getChannel = function(name) { return that.name.toLowerCase() === name; }); }; - -function prettify(host) { - var name = capitalize(host.split(".")[1]); - if (!name) { - name = host; - } - return name; -} - -function capitalize(str) { - if (typeof str === "string") { - return str.charAt(0).toUpperCase() + str.slice(1); - } -} diff --git a/src/models/user.js b/src/models/user.js index 1585e541..2f8340f0 100644 --- a/src/models/user.js +++ b/src/models/user.js @@ -7,13 +7,25 @@ module.exports = User; function User(attr, prefixLookup) { _.defaults(this, attr, { modes: [], - nick: "" + mode: "", + nick: "", + lastMessage: 0, }); - // irc-framework sets character mode, but lounge works with symbols - this.modes = this.modes.map(mode => prefixLookup[mode]); - - // TODO: Remove this - this.name = this.nick; - this.mode = (this.modes && this.modes[0]) || ""; + this.setModes(this.modes, prefixLookup); } + +User.prototype.setModes = function(modes, prefixLookup) { + // irc-framework sets character mode, but lounge works with symbols + this.modes = modes.map((mode) => prefixLookup[mode]); + + this.mode = this.modes[0] || ""; +}; + +User.prototype.toJSON = function() { + return { + nick: this.nick, + mode: this.mode, + lastMessage: this.lastMessage, + }; +}; diff --git a/src/plugins/inputs/away.js b/src/plugins/inputs/away.js index 201559fe..aa4d604b 100644 --- a/src/plugins/inputs/away.js +++ b/src/plugins/inputs/away.js @@ -3,17 +3,17 @@ exports.commands = ["away", "back"]; exports.input = function(network, chan, cmd, args) { - if (cmd === "away") { - let reason = " "; + let reason = ""; - if (args.length > 0) { - reason = args.join(" "); - } + if (cmd === "away") { + reason = args.length > 0 ? args.join(" ") : " "; network.irc.raw("AWAY", reason); - - return; + } else { // back command + network.irc.raw("AWAY"); } - network.irc.raw("AWAY"); + network.awayMessage = reason; + + this.save(); }; diff --git a/src/plugins/inputs/ban.js b/src/plugins/inputs/ban.js new file mode 100644 index 00000000..b6745652 --- /dev/null +++ b/src/plugins/inputs/ban.js @@ -0,0 +1,44 @@ +"use strict"; + +var Chan = require("../../models/chan"); +var Msg = require("../../models/msg"); + +exports.commands = [ + "ban", + "unban", + "banlist" +]; + +exports.input = function(network, chan, cmd, args) { + if (chan.type !== Chan.Type.CHANNEL) { + chan.pushMessage(this, new Msg({ + type: Msg.Type.ERROR, + text: `${cmd} command can only be used in channels.` + })); + + return; + } + + if (cmd !== "banlist" && args.length === 0) { + if (args.length === 0) { + chan.pushMessage(this, new Msg({ + type: Msg.Type.ERROR, + text: `Usage: /${cmd} ` + })); + + return; + } + } + + switch (cmd) { + case "ban": + network.irc.ban(chan.name, args[0]); + break; + case "unban": + network.irc.unban(chan.name, args[0]); + break; + case "banlist": + network.irc.banlist(chan.name); + break; + } +}; diff --git a/src/plugins/inputs/disconnect.js b/src/plugins/inputs/disconnect.js index bd43e40d..5c414884 100644 --- a/src/plugins/inputs/disconnect.js +++ b/src/plugins/inputs/disconnect.js @@ -1,9 +1,11 @@ "use strict"; +const Helper = require("../../helper"); + exports.commands = ["disconnect"]; exports.input = function(network, chan, cmd, args) { - var quitMessage = args[0] ? args.join(" ") : ""; + var quitMessage = args[0] ? args.join(" ") : Helper.config.leaveMessage; network.irc.quit(quitMessage); }; diff --git a/src/plugins/inputs/list.js b/src/plugins/inputs/list.js index 79512a48..4ef63b4a 100644 --- a/src/plugins/inputs/list.js +++ b/src/plugins/inputs/list.js @@ -4,6 +4,6 @@ exports.commands = ["list"]; exports.input = function(network, chan, cmd, args) { network.chanCache = []; - network.irc.list(args); + network.irc.list.apply(network.irc, args); return true; }; diff --git a/src/plugins/inputs/mode.js b/src/plugins/inputs/mode.js index b6ee5d80..4e1227ad 100644 --- a/src/plugins/inputs/mode.js +++ b/src/plugins/inputs/mode.js @@ -1,7 +1,5 @@ "use strict"; -exports.commands = ["mode", "op", "voice", "deop", "devoice"]; - var Chan = require("../../models/chan"); var Msg = require("../../models/msg"); diff --git a/src/plugins/inputs/part.js b/src/plugins/inputs/part.js index 6a837271..88a46563 100644 --- a/src/plugins/inputs/part.js +++ b/src/plugins/inputs/part.js @@ -3,6 +3,7 @@ var _ = require("lodash"); var Msg = require("../../models/msg"); var Chan = require("../../models/chan"); +const Helper = require("../../helper"); exports.commands = ["close", "leave", "part"]; exports.allowDisconnected = true; @@ -17,6 +18,7 @@ exports.input = function(network, chan, cmd, args) { } network.channels = _.without(network.channels, chan); + chan.destroy(); this.emit("part", { chan: chan.id }); @@ -25,7 +27,8 @@ exports.input = function(network, chan, cmd, args) { this.save(); if (network.irc) { - network.irc.part(chan.name, args.join(" ")); + const partMessage = args[0] ? args.join(" ") : Helper.config.leaveMessage; + network.irc.part(chan.name, partMessage); } } diff --git a/src/plugins/inputs/quit.js b/src/plugins/inputs/quit.js index be41378a..8ef2950f 100644 --- a/src/plugins/inputs/quit.js +++ b/src/plugins/inputs/quit.js @@ -1,23 +1,24 @@ "use strict"; var _ = require("lodash"); +const Helper = require("../../helper"); exports.commands = ["quit"]; exports.allowDisconnected = true; exports.input = function(network, chan, cmd, args) { var client = this; - var irc = network.irc; - var quitMessage = args[0] ? args.join(" ") : ""; client.networks = _.without(client.networks, network); + network.destroy(); client.save(); client.emit("quit", { network: network.id }); - if (irc) { - irc.quit(quitMessage); + if (network.irc) { + const quitMessage = args[0] ? args.join(" ") : Helper.config.leaveMessage; + network.irc.quit(quitMessage); } return true; diff --git a/src/plugins/inputs/topic.js b/src/plugins/inputs/topic.js index 8e2327cc..ef7a7cb4 100644 --- a/src/plugins/inputs/topic.js +++ b/src/plugins/inputs/topic.js @@ -14,9 +14,6 @@ exports.input = function(network, chan, cmd, args) { return; } - - var irc = network.irc; - irc.raw("TOPIC", chan.name, args.join(" ")); - + network.irc.setTopic(chan.name, args.join(" ")); return true; }; diff --git a/src/plugins/irc-events/banlist.js b/src/plugins/irc-events/banlist.js new file mode 100644 index 00000000..fa5993ea --- /dev/null +++ b/src/plugins/irc-events/banlist.js @@ -0,0 +1,48 @@ +"use strict"; + +const Chan = require("../../models/chan"); +const Msg = require("../../models/msg"); + +module.exports = function(irc, network) { + const client = this; + + irc.on("banlist", function(banlist) { + const channel = banlist.channel; + const bans = banlist.bans; + if (!bans || bans.length === 0) { + const msg = new Msg({ + time: Date.now(), + type: Msg.Type.ERROR, + text: "Banlist empty" + }); + network.getChannel(channel).pushMessage(client, msg, true); + return; + } + + const chanName = `Banlist for ${channel}`; + let chan = network.getChannel(chanName); + if (typeof chan === "undefined") { + chan = new Chan({ + type: Chan.Type.SPECIAL, + name: chanName + }); + network.channels.push(chan); + client.emit("join", { + network: network.id, + chan: chan + }); + } + + chan.pushMessage(client, + new Msg({ + type: Msg.Type.BANLIST, + bans: bans.map((data) => ({ + hostmask: data.banned, + banned_by: data.banned_by, + banned_at: data.banned_at * 1000 + })) + }), + true + ); + }); +}; diff --git a/src/plugins/irc-events/connection.js b/src/plugins/irc-events/connection.js index a8ecef54..92222117 100644 --- a/src/plugins/irc-events/connection.js +++ b/src/plugins/irc-events/connection.js @@ -1,5 +1,6 @@ "use strict"; +var _ = require("lodash"); var Msg = require("../../models/msg"); var Chan = require("../../models/chan"); var Helper = require("../../helper"); @@ -18,10 +19,18 @@ module.exports = function(irc, network) { }), true); } + // Always restore away message for this network + if (network.awayMessage) { + irc.raw("AWAY", network.awayMessage); + // Only set generic away message if there are no clients attached + } else if (client.awayMessage && _.size(client.attachedClients) === 0) { + irc.raw("AWAY", client.awayMessage); + } + var delay = 1000; var commands = network.commands; if (Array.isArray(commands)) { - commands.forEach(cmd => { + commands.forEach((cmd) => { setTimeout(function() { client.input({ target: network.channels[0].id, @@ -32,13 +41,13 @@ module.exports = function(irc, network) { }); } - network.channels.forEach(chan => { + network.channels.forEach((chan) => { if (chan.type !== Chan.Type.CHANNEL) { return; } setTimeout(function() { - network.irc.join(chan.name); + network.irc.join(chan.name, chan.key); }, delay); delay += 1000; }); @@ -111,17 +120,18 @@ module.exports = function(irc, network) { }); irc.on("server options", function(data) { - if (network.serverOptions.PREFIX === data.options.PREFIX) { + if (network.serverOptions.PREFIX === data.options.PREFIX && network.serverOptions.NETWORK === data.options.NETWORK) { return; } network.prefixLookup = {}; - data.options.PREFIX.forEach(mode => { + data.options.PREFIX.forEach((mode) => { network.prefixLookup[mode.mode] = mode.symbol; }); network.serverOptions.PREFIX = data.options.PREFIX; + network.serverOptions.NETWORK = data.options.NETWORK; client.emit("network_changed", { network: network.id, diff --git a/src/plugins/irc-events/ctcp.js b/src/plugins/irc-events/ctcp.js index a4ed7033..8686493c 100644 --- a/src/plugins/irc-events/ctcp.js +++ b/src/plugins/irc-events/ctcp.js @@ -1,17 +1,17 @@ "use strict"; -var Msg = require("../../models/msg"); +const Msg = require("../../models/msg"); module.exports = function(irc, network) { - var client = this; + const client = this; irc.on("ctcp response", function(data) { - var chan = network.getChannel(data.nick); + let chan = network.getChannel(data.nick); if (typeof chan === "undefined") { chan = network.channels[0]; } - var msg = new Msg({ + const msg = new Msg({ type: Msg.Type.CTCP, time: data.time, from: data.nick, @@ -21,14 +21,20 @@ module.exports = function(irc, network) { chan.pushMessage(client, msg); }); - irc.on("ctcp request", function(data) { + irc.on("ctcp request", (data) => { switch (data.type) { - case "PING": - var split = data.message.split(" "); + case "PING": { + const split = data.message.split(" "); if (split.length === 2) { irc.ctcpResponse(data.nick, "PING", split[1]); } break; } + case "SOURCE": { + const packageJson = require("../../../package.json"); + irc.ctcpResponse(data.nick, "SOURCE", packageJson.repository.url); + break; + } + } }); }; diff --git a/src/plugins/irc-events/join.js b/src/plugins/irc-events/join.js index c7f91ec9..1ad88e6f 100644 --- a/src/plugins/irc-events/join.js +++ b/src/plugins/irc-events/join.js @@ -18,6 +18,9 @@ module.exports = function(irc, network) { network: network.id, chan: chan }); + + // Request channels' modes + network.irc.raw("MODE", chan.name); } chan.users.push(new User({nick: data.nick})); chan.sortUsers(irc); diff --git a/src/plugins/irc-events/kick.js b/src/plugins/irc-events/kick.js index a02c0363..6b3174e6 100644 --- a/src/plugins/irc-events/kick.js +++ b/src/plugins/irc-events/kick.js @@ -11,10 +11,12 @@ module.exports = function(irc, network) { return; } + const user = chan.findUser(data.kicked); + if (data.kicked === irc.user.nick) { chan.users = []; } else { - chan.users = _.without(chan.users, _.find(chan.users, {name: data.kicked})); + chan.users = _.without(chan.users, user); } client.emit("users", { @@ -24,7 +26,7 @@ module.exports = function(irc, network) { var msg = new Msg({ type: Msg.Type.KICK, time: data.time, - mode: chan.getMode(data.nick), + mode: user.mode, from: data.nick, target: data.kicked, text: data.message || "", diff --git a/src/plugins/irc-events/link.js b/src/plugins/irc-events/link.js index 2502446d..06049b15 100644 --- a/src/plugins/irc-events/link.js +++ b/src/plugins/irc-events/link.js @@ -1,88 +1,151 @@ "use strict"; const cheerio = require("cheerio"); -const Msg = require("../../models/msg"); const request = require("request"); +const url = require("url"); const Helper = require("../../helper"); +const findLinks = require("../../../client/js/libs/handlebars/ircmessageparser/findLinks"); const es = require("event-stream"); +const storage = require("../storage"); process.setMaxListeners(0); -module.exports = function(client, chan, originalMsg) { +module.exports = function(client, chan, msg) { if (!Helper.config.prefetch) { return; } - const links = originalMsg.text - .replace(/\x02|\x1D|\x1F|\x16|\x0F|\x03(?:[0-9]{1,2}(?:,[0-9]{1,2})?)?/g, "") - .split(" ") - .filter(w => /^https?:\/\//.test(w)); + // Remove all IRC formatting characters before searching for links + const cleanText = Helper.cleanIrcMessage(msg.text); + + // We will only try to prefetch http(s) links + const links = findLinks(cleanText).filter((w) => /^https?:\/\//.test(w.link)); if (links.length === 0) { return; } - let msg = new Msg({ - type: Msg.Type.TOGGLE, - time: originalMsg.time, - self: originalMsg.self, - }); - chan.pushMessage(client, msg); - - const link = escapeHeader(links[0]); - fetch(link, function(res) { - parse(msg, link, res, client); - }); -}; - -function parse(msg, url, res, client) { - var toggle = msg.toggle = { - id: msg.id, - type: "", + msg.previews = Array.from(new Set( // Remove duplicate links + links.map((link) => escapeHeader(link.link)) + )).map((link) => ({ + type: "loading", head: "", body: "", thumb: "", - link: url, - }; + link: link, + shown: true, + })).slice(0, 5); // Only preview the first 5 URLs in message to avoid abuse + msg.previews.forEach((preview) => { + fetch(preview.link, function(res) { + if (res === null) { + return; + } + + parse(msg, preview, res, client); + }); + }); +}; + +function parse(msg, preview, res, client) { switch (res.type) { case "text/html": - var $ = cheerio.load(res.text); - toggle.type = "link"; - toggle.head = $("title").text(); - toggle.body = - $("meta[name=description]").attr("content") - || $("meta[property=\"og:description\"]").attr("content") - || "No description found."; - toggle.thumb = + var $ = cheerio.load(res.data); + preview.type = "link"; + preview.head = + $("meta[property=\"og:title\"]").attr("content") + || $("title").text() + || ""; + preview.body = + $("meta[property=\"og:description\"]").attr("content") + || $("meta[name=\"description\"]").attr("content") + || ""; + preview.thumb = $("meta[property=\"og:image\"]").attr("content") || $("meta[name=\"twitter:image:src\"]").attr("content") + || $("link[rel=\"image_src\"]").attr("href") || ""; + + if (preview.thumb.length) { + preview.thumb = url.resolve(preview.link, preview.thumb); + } + + // Make sure thumbnail is a valid url + if (!/^https?:\/\//.test(preview.thumb)) { + preview.thumb = ""; + } + + // Verify that thumbnail pic exists and is under allowed size + if (preview.thumb.length) { + fetch(escapeHeader(preview.thumb), (resThumb) => { + if (resThumb === null + || !(/^image\/.+/.test(resThumb.type)) + || resThumb.size > (Helper.config.prefetchMaxImageSize * 1024)) { + preview.thumb = ""; + } + + handlePreview(client, msg, preview, resThumb); + }); + + return; + } + break; case "image/png": case "image/gif": case "image/jpg": case "image/jpeg": - if (res.size < (Helper.config.prefetchMaxImageSize * 1024)) { - toggle.type = "image"; - } else { + if (res.size > (Helper.config.prefetchMaxImageSize * 1024)) { return; } + + preview.type = "image"; + preview.thumb = preview.link; + break; default: return; } - client.emit("toggle", toggle); + handlePreview(client, msg, preview, res); } -function fetch(url, cb) { +function handlePreview(client, msg, preview, res) { + if (!preview.thumb.length || !Helper.config.prefetchStorage) { + return emitPreview(client, msg, preview); + } + + storage.store(res.data, res.type.replace("image/", ""), (uri) => { + preview.thumb = uri; + + emitPreview(client, msg, preview); + }); +} + +function emitPreview(client, msg, preview) { + // If there is no title but there is preview or description, set title + // otherwise bail out and show no preview + if (!preview.head.length && preview.type === "link") { + if (preview.thumb.length || preview.body.length) { + preview.head = "Untitled page"; + } else { + return; + } + } + + client.emit("msg:preview", { + id: msg.id, + preview: preview + }); +} + +function fetch(uri, cb) { let req; try { req = request.get({ - url: url, + url: uri, maxRedirects: 5, timeout: 5000, headers: { @@ -90,14 +153,16 @@ function fetch(url, cb) { } }); } catch (e) { - return; + return cb(null); } var length = 0; - var limit = 1024 * 10; + var limit = Helper.config.prefetchMaxImageSize * 1024; req .on("response", function(res) { - if (!(/(text\/html|application\/json)/.test(res.headers["content-type"]))) { - res.req.abort(); + if (!(/^image\/.+/.test(res.headers["content-type"]))) { + // if not image, limit download to 50kb, since we need only meta tags + // twitter.com sends opengraph meta tags within ~20kb of data for individual tweets + limit = 1024 * 50; } }) .on("error", function() {}) @@ -110,28 +175,30 @@ function fetch(url, cb) { })) .pipe(es.wait(function(err, data) { if (err) { - return; + return cb(null); } - var body; - var type; - var size = req.response.headers["content-length"]; - try { - body = JSON.parse(data); - } catch (e) { - body = {}; + if (req.response.statusCode < 200 || req.response.statusCode > 299) { + return cb(null); } - try { + + let type = ""; + let size = parseInt(req.response.headers["content-length"], 10) || length; + + if (size < length) { + size = length; + } + + if (req.response.headers["content-type"]) { type = req.response.headers["content-type"].split(/ *; */).shift(); - } catch (e) { - type = {}; } + data = { - text: data, - body: body, + data: data, type: type, size: size }; + cb(data); })); } diff --git a/src/plugins/irc-events/list.js b/src/plugins/irc-events/list.js index 0dbf2881..848f912d 100644 --- a/src/plugins/irc-events/list.js +++ b/src/plugins/irc-events/list.js @@ -5,7 +5,7 @@ var Msg = require("../../models/msg"); module.exports = function(irc, network) { var client = this; - var MAX_CHANS = 1000; + var MAX_CHANS = 500; irc.on("channel list start", function() { network.chanCache = []; @@ -23,7 +23,9 @@ module.exports = function(irc, network) { irc.on("channel list end", function() { updateListStatus(new Msg({ type: "channel_list", - channels: network.chanCache.slice(0, MAX_CHANS) + channels: network.chanCache.sort(function(a, b) { + return b.num_users - a.num_users; + }).slice(0, MAX_CHANS) })); if (network.chanCache.length > MAX_CHANS) { diff --git a/src/plugins/irc-events/message.js b/src/plugins/irc-events/message.js index f83f1b4a..fbfc7102 100644 --- a/src/plugins/irc-events/message.js +++ b/src/plugins/irc-events/message.js @@ -3,6 +3,7 @@ const Chan = require("../../models/chan"); const Msg = require("../../models/msg"); const LinkPrefetch = require("./link"); +const Helper = require("../../helper"); module.exports = function(irc, network) { var client = this; @@ -71,6 +72,12 @@ module.exports = function(irc, network) { // Query messages (unless self) always highlight if (chan.type === Chan.Type.QUERY) { highlight = !self; + } else if (chan.type === Chan.Type.CHANNEL) { + const user = chan.findUser(data.nick); + + if (user) { + user.lastMessage = data.time || Date.now(); + } } } @@ -89,11 +96,31 @@ module.exports = function(irc, network) { self: self, highlight: highlight }); - chan.pushMessage(client, msg, !self); // No prefetch URLs unless are simple MESSAGE or ACTION types if ([Msg.Type.MESSAGE, Msg.Type.ACTION].indexOf(data.type) !== -1) { LinkPrefetch(client, chan, msg); } + + chan.pushMessage(client, msg, !self); + + // Do not send notifications for messages older than 15 minutes (znc buffer for example) + if (highlight && (!data.time || data.time > Date.now() - 900000)) { + let title = data.nick; + + if (chan.type !== Chan.Type.QUERY) { + title += ` (${chan.name}) mentioned you`; + } else { + title += " sent you a message"; + } + + client.manager.webPush.push(client, { + type: "notification", + chanId: chan.id, + timestamp: data.time || Date.now(), + title: `The Lounge: ${title}`, + body: Helper.cleanIrcMessage(data.message) + }, true); + } } }; diff --git a/src/plugins/irc-events/mode.js b/src/plugins/irc-events/mode.js index b47985c9..266515bb 100644 --- a/src/plugins/irc-events/mode.js +++ b/src/plugins/irc-events/mode.js @@ -1,13 +1,40 @@ "use strict"; -var _ = require("lodash"); -var Chan = require("../../models/chan"); -var Msg = require("../../models/msg"); +const _ = require("lodash"); +const Chan = require("../../models/chan"); +const Msg = require("../../models/msg"); module.exports = function(irc, network) { - var client = this; + const client = this; + + // The following saves the channel key based on channel mode instead of + // extracting it from `/join #channel key`. This lets us not have to + // temporarily store the key until successful join, but also saves the key + // if a key is set or changed while being on the channel. + irc.on("channel info", function(data) { + if (!data.modes) { + return; + } + + const targetChan = network.getChannel(data.channel); + if (typeof targetChan === "undefined") { + return; + } + + data.modes.forEach((mode) => { + const text = mode.mode; + const add = text[0] === "+"; + const char = text[1]; + + if (char === "k") { + targetChan.key = add ? mode.param : ""; + client.save(); + } + }); + }); + irc.on("mode", function(data) { - var targetChan; + let targetChan; if (data.target === irc.user.nick) { targetChan = network.channels[0]; @@ -18,23 +45,29 @@ module.exports = function(irc, network) { } } - var usersUpdated; - var supportsMultiPrefix = network.irc.network.cap.isEnabled("multi-prefix"); - var userModeSortPriority = {}; + let usersUpdated; + const userModeSortPriority = {}; + const supportsMultiPrefix = network.irc.network.cap.isEnabled("multi-prefix"); irc.network.options.PREFIX.forEach((prefix, index) => { userModeSortPriority[prefix.symbol] = index; }); - for (var i = 0; i < data.modes.length; i++) { - var mode = data.modes[i]; - var text = mode.mode; + data.modes.forEach((mode) => { + let text = mode.mode; + const add = text[0] === "+"; + const char = text[1]; + + if (char === "k") { + targetChan.key = add ? mode.param : ""; + client.save(); + } if (mode.param) { text += " " + mode.param; } - var msg = new Msg({ + const msg = new Msg({ time: data.time, type: Msg.Type.MODE, mode: (targetChan.type !== Chan.Type.LOBBY && targetChan.getMode(data.nick)) || "", @@ -45,22 +78,21 @@ module.exports = function(irc, network) { targetChan.pushMessage(client, msg); if (!mode.param) { - continue; + return; } - var user = _.find(targetChan.users, {name: mode.param}); + const user = targetChan.findUser(mode.param); if (!user) { - continue; + return; } usersUpdated = true; if (!supportsMultiPrefix) { - continue; + return; } - var add = mode.mode[0] === "+"; - var changedMode = network.prefixLookup[mode.mode[1]]; + const changedMode = network.prefixLookup[char]; if (!add) { _.pull(user.modes, changedMode); @@ -73,7 +105,7 @@ module.exports = function(irc, network) { // TODO: remove in future user.mode = (user.modes && user.modes[0]) || ""; - } + }); if (!usersUpdated) { return; diff --git a/src/plugins/irc-events/motd.js b/src/plugins/irc-events/motd.js index 0d777869..062943b3 100644 --- a/src/plugins/irc-events/motd.js +++ b/src/plugins/irc-events/motd.js @@ -8,7 +8,7 @@ module.exports = function(irc, network) { var lobby = network.channels[0]; if (data.motd) { - data.motd.split("\n").forEach(text => { + data.motd.split("\n").forEach((text) => { var msg = new Msg({ type: Msg.Type.MOTD, text: text diff --git a/src/plugins/irc-events/names.js b/src/plugins/irc-events/names.js index daae14b3..c75f860f 100644 --- a/src/plugins/irc-events/names.js +++ b/src/plugins/irc-events/names.js @@ -1,16 +1,39 @@ "use strict"; -var User = require("../../models/user"); +const User = require("../../models/user"); module.exports = function(irc, network) { - var client = this; + const client = this; + irc.on("userlist", function(data) { - var chan = network.getChannel(data.channel); + const chan = network.getChannel(data.channel); if (typeof chan === "undefined") { return; } - chan.users = data.users.map(u => new User(u, network.prefixLookup)); + // Create lookup map of current users, + // as we need to keep certain properties + // and we can recycle existing User objects + const oldUsers = new Map(); + + chan.users.forEach((user) => { + oldUsers.set(user.nick, user); + }); + + chan.users = data.users.map((user) => { + const oldUser = oldUsers.get(user.nick); + + // For existing users, we only need to update mode + if (oldUser) { + oldUser.setModes(user.modes, network.prefixLookup); + return oldUser; + } + + return new User({ + nick: user.nick, + modes: user.modes, + }, network.prefixLookup); + }); chan.sortUsers(irc); diff --git a/src/plugins/irc-events/nick.js b/src/plugins/irc-events/nick.js index c8519530..af10f233 100644 --- a/src/plugins/irc-events/nick.js +++ b/src/plugins/irc-events/nick.js @@ -1,6 +1,5 @@ "use strict"; -var _ = require("lodash"); var Msg = require("../../models/msg"); module.exports = function(irc, network) { @@ -24,12 +23,12 @@ module.exports = function(irc, network) { }); } - network.channels.forEach(chan => { - var user = _.find(chan.users, {name: data.nick}); + network.channels.forEach((chan) => { + const user = chan.findUser(data.nick); if (typeof user === "undefined") { return; } - user.name = data.new_nick; + user.nick = data.new_nick; chan.sortUsers(irc); client.emit("users", { chan: chan.id diff --git a/src/plugins/irc-events/part.js b/src/plugins/irc-events/part.js index 6711aced..09053687 100644 --- a/src/plugins/irc-events/part.js +++ b/src/plugins/irc-events/part.js @@ -13,12 +13,13 @@ module.exports = function(irc, network) { var from = data.nick; if (from === irc.user.nick) { network.channels = _.without(network.channels, chan); + chan.destroy(); client.save(); client.emit("part", { chan: chan.id }); } else { - var user = _.find(chan.users, {name: from}); + const user = chan.findUser(from); chan.users = _.without(chan.users, user); client.emit("users", { chan: chan.id diff --git a/src/plugins/irc-events/quit.js b/src/plugins/irc-events/quit.js index 1684f132..fd0c3caa 100644 --- a/src/plugins/irc-events/quit.js +++ b/src/plugins/irc-events/quit.js @@ -6,9 +6,8 @@ var Msg = require("../../models/msg"); module.exports = function(irc, network) { var client = this; irc.on("quit", function(data) { - network.channels.forEach(chan => { - var from = data.nick; - var user = _.find(chan.users, {name: from}); + network.channels.forEach((chan) => { + const user = chan.findUser(data.nick); if (typeof user === "undefined") { return; } @@ -22,7 +21,7 @@ module.exports = function(irc, network) { mode: user.mode || "", text: data.message || "", hostmask: data.ident + "@" + data.hostname, - from: from + from: data.nick }); chan.pushMessage(client, msg); }); diff --git a/src/plugins/storage.js b/src/plugins/storage.js new file mode 100644 index 00000000..b96bf7af --- /dev/null +++ b/src/plugins/storage.js @@ -0,0 +1,75 @@ +"use strict"; + +const fs = require("fs"); +const fsextra = require("fs-extra"); +const path = require("path"); +const crypto = require("crypto"); +const helper = require("../helper"); + +class Storage { + constructor() { + this.references = new Map(); + + // Ensures that a directory is empty. + // Deletes directory contents if the directory is not empty. + // If the directory does not exist, it is created. + fsextra.emptyDirSync(helper.getStoragePath()); + } + + dereference(url) { + const references = (this.references.get(url) || 0) - 1; + + if (references < 0) { + return log.warn("Tried to dereference a file that has no references", url); + } + + if (references > 0) { + return this.references.set(url, references); + } + + this.references.delete(url); + + // Drop "storage/" from url and join it with full storage path + const filePath = path.join(helper.getStoragePath(), url.substring(8)); + + fs.unlink(filePath, (err) => { + if (err) { + log.error("Failed to delete stored file", err); + } + }); + } + + store(data, extension, callback) { + const hash = crypto.createHash("sha256").update(data).digest("hex"); + const a = hash.substring(0, 2); + const b = hash.substring(2, 4); + const folder = path.join(helper.getStoragePath(), a, b); + const filePath = path.join(folder, `${hash.substring(4)}.${extension}`); + const url = `storage/${a}/${b}/${hash.substring(4)}.${extension}`; + + this.references.set(url, 1 + (this.references.get(url) || 0)); + + // If file with this name already exists, we don't need to write it again + if (fs.existsSync(filePath)) { + return callback(url); + } + + fsextra.ensureDir(folder).then(() => { + fs.writeFile(filePath, data, (err) => { + if (err) { + log.error("Failed to store a file", err); + + return callback(""); + } + + callback(url); + }); + }).catch((err) => { + log.error("Failed to create storage folder", err); + + return callback(""); + }); + } +} + +module.exports = new Storage(); diff --git a/src/plugins/webpush.js b/src/plugins/webpush.js new file mode 100644 index 00000000..4b867e95 --- /dev/null +++ b/src/plugins/webpush.js @@ -0,0 +1,73 @@ +"use strict"; + +const _ = require("lodash"); +const fs = require("fs"); +const path = require("path"); +const WebPushAPI = require("web-push"); +const Helper = require("../helper"); + +class WebPush { + constructor() { + const vapidPath = path.join(Helper.HOME, "vapid.json"); + + if (fs.existsSync(vapidPath)) { + const data = fs.readFileSync(vapidPath, "utf-8"); + const parsedData = JSON.parse(data); + + if (typeof parsedData.publicKey === "string" && typeof parsedData.privateKey === "string") { + this.vapidKeys = { + publicKey: parsedData.publicKey, + privateKey: parsedData.privateKey, + }; + } + } + + if (!this.vapidKeys) { + this.vapidKeys = WebPushAPI.generateVAPIDKeys(); + + fs.writeFileSync(vapidPath, JSON.stringify(this.vapidKeys, null, "\t")); + + log.info("New VAPID key pair has been generated for use with push subscription."); + } + + WebPushAPI.setVapidDetails( + "https://github.com/thelounge/lounge", + this.vapidKeys.publicKey, + this.vapidKeys.privateKey + ); + } + + push(client, payload, onlyToOffline) { + _.forOwn(client.config.sessions, (session, token) => { + if (session.pushSubscription) { + if (onlyToOffline && _.find(client.attachedClients, {token: token}) !== undefined) { + return; + } + + this.pushSingle(client, session.pushSubscription, payload); + } + }); + } + + pushSingle(client, subscription, payload) { + WebPushAPI + .sendNotification(subscription, JSON.stringify(payload)) + .catch((error) => { + if (error.statusCode >= 400 && error.statusCode < 500) { + log.warn(`WebPush subscription for ${client.name} returned an error (${error.statusCode}), removing subscription`); + + _.forOwn(client.config.sessions, (session, token) => { + if (session.pushSubscription && session.pushSubscription.endpoint === subscription.endpoint) { + client.unregisterPushSubscription(token); + } + }); + + return; + } + + log.error(`WebPush Error (${error})`); + }); + } +} + +module.exports = WebPush; diff --git a/src/server.js b/src/server.js index 6af0fe47..205801cc 100644 --- a/src/server.js +++ b/src/server.js @@ -5,16 +5,18 @@ var pkg = require("../package.json"); var Client = require("./client"); var ClientManager = require("./clientManager"); var express = require("express"); +var expressHandlebars = require("express-handlebars"); var fs = require("fs"); +var path = require("path"); var io = require("socket.io"); var dns = require("dns"); var Helper = require("./helper"); var ldap = require("ldapjs"); var colors = require("colors/safe"); +const net = require("net"); const Identification = require("./identification"); var manager = null; -var authFunction = localAuth; module.exports = function() { log.info(`The Lounge ${colors.green(Helper.getVersion())} \ @@ -29,7 +31,19 @@ module.exports = function() { var app = express() .use(allRequests) .use(index) - .use(express.static("client")); + .use(express.static("client")) + .use("/storage/", express.static(Helper.getStoragePath(), { + redirect: false, + maxAge: 86400 * 1000, + })) + .engine("html", expressHandlebars({ + extname: ".html", + helpers: { + tojson: (c) => JSON.stringify(c) + } + })) + .set("view engine", "html") + .set("views", path.join(__dirname, "..", "client")); var config = Helper.config; var server = null; @@ -42,20 +56,30 @@ module.exports = function() { server = require("http"); server = server.createServer(app); } else { - server = require("spdy"); const keyPath = Helper.expandHome(config.https.key); const certPath = Helper.expandHome(config.https.certificate); - if (!config.https.key.length || !fs.existsSync(keyPath)) { + const caPath = Helper.expandHome(config.https.ca); + + if (!keyPath.length || !fs.existsSync(keyPath)) { log.error("Path to SSL key is invalid. Stopping server..."); process.exit(); } - if (!config.https.certificate.length || !fs.existsSync(certPath)) { + + if (!certPath.length || !fs.existsSync(certPath)) { log.error("Path to SSL certificate is invalid. Stopping server..."); process.exit(); } + + if (caPath.length && !fs.existsSync(caPath)) { + log.error("Path to SSL ca bundle is invalid. Stopping server..."); + process.exit(); + } + + server = require("spdy"); server = server.createServer({ key: fs.readFileSync(keyPath), - cert: fs.readFileSync(certPath) + cert: fs.readFileSync(certPath), + ca: caPath ? fs.readFileSync(caPath) : undefined }, app); } @@ -65,41 +89,44 @@ module.exports = function() { }, () => { const protocol = config.https.enable ? "https" : "http"; var address = server.address(); - log.info(`Available on ${colors.green(protocol + "://" + address.address + ":" + address.port + "/")} \ -in ${config.public ? "public" : "private"} mode`); - }); - if (!config.public && (config.ldap || {}).enable) { - authFunction = ldapAuth; - } + log.info( + "Available at " + + colors.green(`${protocol}://${address.address}:${address.port}/`) + + ` in ${colors.bold(config.public ? "public" : "private")} mode` + ); - var sockets = io(server, { - serveClient: false, - transports: config.transports - }); + const sockets = io(server, { + serveClient: false, + transports: config.transports + }); - sockets.on("connect", function(socket) { - if (config.public) { - auth.call(socket); - } else { - init(socket); - } - }); + sockets.on("connect", (socket) => { + if (config.public) { + performAuthentication.call(socket, {}); + } else { + socket.emit("auth", {success: true}); + socket.on("auth", performAuthentication); + } + }); - manager = new ClientManager(); + manager = new ClientManager(); - new Identification((identHandler) => { - manager.init(identHandler, sockets); + new Identification((identHandler) => { + manager.init(identHandler, sockets); + }); }); }; -function getClientIp(req) { - var ip; +function getClientIp(request) { + let ip = request.connection.remoteAddress; - if (!Helper.config.reverseProxy) { - ip = req.connection.remoteAddress; - } else { - ip = req.headers["x-forwarded-for"] || req.connection.remoteAddress; + if (Helper.config.reverseProxy) { + const forwarded = (request.headers["x-forwarded-for"] || "").split(/\s*,\s*/).filter(Boolean); + + if (forwarded.length && net.isIP(forwarded[0])) { + ip = forwarded[0]; + } } return ip.replace(/^::ffff:/, ""); @@ -115,171 +142,266 @@ function index(req, res, next) { return next(); } - return fs.readFile("client/index.html", "utf-8", function(err, file) { - if (err) { - throw err; - } - - var data = _.merge( - pkg, - Helper.config - ); - data.gitCommit = Helper.getGitCommit(); - data.themes = fs.readdirSync("client/themes/").filter(function(themeFile) { - return themeFile.endsWith(".css"); - }).map(function(css) { - return css.slice(0, -4); - }); - var template = _.template(file); - res.setHeader("Content-Security-Policy", "default-src *; connect-src 'self' ws: wss:; style-src * 'unsafe-inline'; script-src 'self'; child-src 'self'; object-src 'none'; form-action 'none'; referrer no-referrer;"); - res.setHeader("Content-Type", "text/html"); - res.writeHead(200); - res.end(template(data)); + var data = _.merge( + pkg, + Helper.config + ); + data.gitCommit = Helper.getGitCommit(); + data.themes = fs.readdirSync("client/themes/").filter(function(themeFile) { + return themeFile.endsWith(".css"); + }).map(function(css) { + const filename = css.slice(0, -4); + return { + name: filename.charAt(0).toUpperCase() + filename.slice(1), + filename: filename + }; }); + + const policies = [ + "default-src *", + "connect-src 'self' ws: wss:", + "style-src * 'unsafe-inline'", + "script-src 'self'", + "child-src 'self'", + "object-src 'none'", + "form-action 'none'", + ]; + + // If prefetch is enabled, but storage is not, we have to allow mixed content + if (Helper.config.prefetchStorage || !Helper.config.prefetch) { + policies.push("img-src 'self'"); + policies.unshift("block-all-mixed-content"); + } + + res.setHeader("Content-Security-Policy", policies.join("; ")); + res.setHeader("Referrer-Policy", "no-referrer"); + res.render("index", data); } -function init(socket, client) { - if (!client) { - socket.emit("auth", {success: true}); - socket.on("auth", auth); - } else { - socket.emit("authorized"); +function initializeClient(socket, client, generateToken, token) { + socket.emit("authorized"); - client.ip = getClientIp(socket.request); + socket.on("disconnect", function() { + client.clientDetach(socket.id); + }); + client.clientAttach(socket.id, token); - socket.on("disconnect", function() { - client.clientDetach(socket.id); - }); - client.clientAttach(socket.id); - - socket.on( - "input", - function(data) { - client.input(data); - } - ); - socket.on( - "more", - function(data) { - client.more(data); - } - ); - socket.on( - "conn", - function(data) { - // prevent people from overriding webirc settings - data.ip = null; - data.hostname = null; - client.connect(data); - } - ); - if (!Helper.config.public && !Helper.config.ldap.enable) { - socket.on( - "change-password", - function(data) { - var old = data.old_password; - var p1 = data.new_password; - var p2 = data.verify_password; - if (typeof p1 === "undefined" || p1 === "") { - socket.emit("change-password", { - error: "Please enter a new password" - }); - return; - } - if (p1 !== p2) { - socket.emit("change-password", { - error: "Both new password fields must match" - }); - return; - } - if (!Helper.password.compare(old || "", client.config.password)) { - socket.emit("change-password", { - error: "The current password field does not match your account password" - }); - return; - } - - var hash = Helper.password.hash(p1); - - client.setPassword(hash, function(success) { - var obj = {}; - - if (success) { - obj.success = "Successfully updated your password, all your other sessions were logged out"; - obj.token = client.config.token; - } else { - obj.error = "Failed to update your password"; - } - - socket.emit("change-password", obj); - }); - } - ); + socket.on( + "input", + function(data) { + client.input(data); } + ); + + socket.on( + "more", + function(data) { + client.more(data); + } + ); + + socket.on( + "conn", + function(data) { + // prevent people from overriding webirc settings + data.ip = null; + data.hostname = null; + + client.connect(data); + } + ); + + if (!Helper.config.public && !Helper.config.ldap.enable) { socket.on( - "open", + "change-password", function(data) { - client.open(socket.id, data); + var old = data.old_password; + var p1 = data.new_password; + var p2 = data.verify_password; + if (typeof p1 === "undefined" || p1 === "") { + socket.emit("change-password", { + error: "Please enter a new password" + }); + return; + } + if (p1 !== p2) { + socket.emit("change-password", { + error: "Both new password fields must match" + }); + return; + } + + Helper.password + .compare(old || "", client.config.password) + .then((matching) => { + if (!matching) { + socket.emit("change-password", { + error: "The current password field does not match your account password" + }); + return; + } + const hash = Helper.password.hash(p1); + + client.setPassword(hash, (success) => { + const obj = {}; + + if (success) { + obj.success = "Successfully updated your password"; + } else { + obj.error = "Failed to update your password"; + } + + socket.emit("change-password", obj); + }); + }).catch((error) => { + log.error(`Error while checking users password. Error: ${error}`); + }); } ); - socket.on( - "sort", - function(data) { - client.sort(data); + } + + socket.on( + "open", + function(data) { + client.open(socket.id, data); + } + ); + + socket.on( + "sort", + function(data) { + client.sort(data); + } + ); + + socket.on( + "names", + function(data) { + client.names(data); + } + ); + + socket.on("msg:preview:toggle", function(data) { + const networkAndChan = client.find(data.target); + if (!networkAndChan) { + return; + } + + const message = networkAndChan.chan.findMessage(data.msgId); + + if (!message) { + return; + } + + const preview = message.findPreview(data.link); + + if (preview) { + preview.shown = data.shown; + } + }); + + socket.on("push:register", (subscription) => { + if (!client.isRegistered() || !client.config.sessions[token]) { + return; + } + + const registration = client.registerPushSubscription(client.config.sessions[token], subscription); + + if (registration) { + client.manager.webPush.pushSingle(client, registration, { + type: "notification", + timestamp: Date.now(), + title: "The Lounge", + body: "🚀 Push notifications have been enabled" + }); + } + }); + + socket.on("push:unregister", () => { + if (!client.isRegistered()) { + return; + } + + client.unregisterPushSubscription(token); + }); + + socket.on("sign-out", () => { + delete client.config.sessions[token]; + + client.manager.updateUser(client.name, { + sessions: client.config.sessions + }, (err) => { + if (err) { + log.error("Failed to update sessions for", client.name, err); } - ); - socket.on( - "names", - function(data) { - client.names(data); - } - ); - socket.join(client.id); + }); + + socket.emit("sign-out"); + }); + + socket.join(client.id); + + const sendInitEvent = (tokenToSend) => { socket.emit("init", { + applicationServerKey: manager.webPush.vapidKeys.publicKey, + pushSubscription: client.config.sessions[token], active: client.lastActiveChannel, networks: client.networks, - token: client.config.token || null + token: tokenToSend }); + }; + + if (generateToken) { + client.generateToken((newToken) => { + token = newToken; + + client.updateSession(token, getClientIp(socket.request), socket.request); + + client.manager.updateUser(client.name, { + sessions: client.config.sessions + }, (err) => { + if (err) { + log.error("Failed to update sessions for", client.name, err); + } + }); + + sendInitEvent(token); + }); + } else { + sendInitEvent(null); } } -function reverseDnsLookup(socket, client) { - client.ip = getClientIp(socket.request); - - dns.reverse(client.ip, function(err, host) { - if (!err && host.length) { - client.hostname = host[0]; - } else { - client.hostname = client.ip; - } - - init(socket, client); - }); -} - function localAuth(client, user, password, callback) { + // If no user is found, or if the client has not provided a password, + // fail the authentication straight away if (!client || !password) { return callback(false); } + // If this user has no password set, fail the authentication if (!client.config.password) { log.error(`User ${colors.bold(user)} with no local password set tried to sign in. (Probably a LDAP user)`); return callback(false); } - var result = Helper.password.compare(password, client.config.password); + Helper.password + .compare(password, client.config.password) + .then((matching) => { + if (matching && Helper.password.requiresUpdate(client.config.password)) { + const hash = Helper.password.hash(password); - if (result && Helper.password.requiresUpdate(client.config.password)) { - var hash = Helper.password.hash(password); - - client.setPassword(hash, function(success) { - if (success) { - log.info(`User ${colors.bold(client.name)} logged in and their hashed password has been updated to match new security requirements`); + client.setPassword(hash, (success) => { + if (success) { + log.info(`User ${colors.bold(client.name)} logged in and their hashed password has been updated to match new security requirements`); + } + }); } - }); - } - return callback(result); + callback(matching); + }).catch((error) => { + log.error(`Error while checking users password. Error: ${error}`); + }); } function ldapAuth(client, user, password, callback) { @@ -353,51 +475,82 @@ function ldapAuth(client, user, password, callback) { }); } -function auth(data) { - var socket = this; - var client; +function performAuthentication(data) { + const socket = this; + let client; + + const finalInit = () => initializeClient(socket, client, !!data.remember, data.token || null); + + const initClient = () => { + client.ip = getClientIp(socket.request); + + // If webirc is enabled perform reverse dns lookup + if (Helper.config.webirc === null) { + return finalInit(); + } + + reverseDnsLookup(client.ip, (hostname) => { + client.hostname = hostname; + + finalInit(); + }); + }; + if (Helper.config.public) { client = new Client(manager); manager.clients.push(client); + socket.on("disconnect", function() { manager.clients = _.without(manager.clients, client); client.quit(); }); - if (Helper.config.webirc) { - reverseDnsLookup(socket, client); - } else { - init(socket, client); + + initClient(); + + return; + } + + const authCallback = (success) => { + // Authorization failed + if (!success) { + socket.emit("auth", {success: false}); + return; } + + // If authorization succeeded but there is no loaded user, + // load it and find the user again (this happens with LDAP) + if (!client) { + manager.loadUser(data.user); + client = manager.findClient(data.user); + } + + initClient(); + }; + + client = manager.findClient(data.user); + + // We have found an existing user and client has provided a token + if (client && data.token && typeof client.config.sessions[data.token] !== "undefined") { + client.updateSession(data.token, getClientIp(socket.request), socket.request); + + authCallback(true); + return; + } + + // Perform password checking + if (!Helper.config.public && Helper.config.ldap.enable) { + ldapAuth(client, data.user, data.password, authCallback); } else { - client = manager.findClient(data.user, data.token); - var signedIn = data.token && client && data.token === client.config.token; - var token; - - if (client && (data.remember || data.token)) { - token = client.config.token; - } - - var authCallback = function(success) { - if (success) { - if (!client) { - // LDAP just created a user - manager.loadUser(data.user); - client = manager.findClient(data.user); - } - if (Helper.config.webirc !== null && !client.config.ip) { - reverseDnsLookup(socket, client); - } else { - init(socket, client, token); - } - } else { - socket.emit("auth", {success: success}); - } - }; - - if (signedIn) { - authCallback(true); - } else { - authFunction(client, data.user, data.password, authCallback); - } + localAuth(client, data.user, data.password, authCallback); } } + +function reverseDnsLookup(ip, callback) { + dns.reverse(ip, (err, hostnames) => { + if (!err && hostnames.length) { + return callback(hostnames[0]); + } + + callback(ip); + }); +} diff --git a/src/userLog.js b/src/userLog.js index 4b3020f8..3d65bb9f 100644 --- a/src/userLog.js +++ b/src/userLog.js @@ -11,7 +11,7 @@ module.exports.write = function(user, network, chan, msg) { try { fsextra.ensureDirSync(path); } catch (e) { - log.error("Unabled to create logs directory", e); + log.error("Unable to create logs directory", e); return; } diff --git a/test/client/js/libs/handlebars/friendlydateTest.js b/test/client/js/libs/handlebars/friendlydateTest.js new file mode 100644 index 00000000..d9091e11 --- /dev/null +++ b/test/client/js/libs/handlebars/friendlydateTest.js @@ -0,0 +1,24 @@ +"use strict"; + +const expect = require("chai").expect; +const moment = require("moment"); +const friendlydate = require("../../../../../client/js/libs/handlebars/friendlydate"); + +describe("friendlydate Handlebars helper", () => { + it("should render 'Today' as a human-friendly date", () => { + const time = new Date().getTime(); + expect(friendlydate(time)).to.equal("Today"); + }); + + it("should render 'Yesterday' as a human-friendly date", () => { + const time = new Date().getTime() - 24 * 3600 * 1000; + expect(friendlydate(time)).to.equal("Yesterday"); + }); + + it("should not render any friendly dates prior to the day before", () => { + [2, 7, 30, 365, 1000].forEach((day) => { + const time = new Date().getTime() - 24 * 3600 * 1000 * day; + expect(friendlydate(time)).to.equal(moment(time).format("D MMMM YYYY")); + }); + }); +}); diff --git a/test/client/js/libs/handlebars/ircmessageparser/anyIntersection.js b/test/client/js/libs/handlebars/ircmessageparser/anyIntersection.js new file mode 100644 index 00000000..b80a44ed --- /dev/null +++ b/test/client/js/libs/handlebars/ircmessageparser/anyIntersection.js @@ -0,0 +1,30 @@ +"use strict"; + +const expect = require("chai").expect; +const anyIntersection = require("../../../../../../client/js/libs/handlebars/ircmessageparser/anyIntersection"); + +describe("anyIntersection", () => { + it("should not intersect on edges", () => { + const a = {start: 1, end: 2}; + const b = {start: 2, end: 3}; + + expect(anyIntersection(a, b)).to.equal(false); + expect(anyIntersection(b, a)).to.equal(false); + }); + + it("should intersect on overlapping", () => { + const a = {start: 0, end: 3}; + const b = {start: 1, end: 2}; + + expect(anyIntersection(a, b)).to.equal(true); + expect(anyIntersection(b, a)).to.equal(true); + }); + + it("should not intersect", () => { + const a = {start: 0, end: 1}; + const b = {start: 2, end: 3}; + + expect(anyIntersection(a, b)).to.equal(false); + expect(anyIntersection(b, a)).to.equal(false); + }); +}); diff --git a/test/client/js/libs/handlebars/ircmessageparser/fill.js b/test/client/js/libs/handlebars/ircmessageparser/fill.js new file mode 100644 index 00000000..8723ad52 --- /dev/null +++ b/test/client/js/libs/handlebars/ircmessageparser/fill.js @@ -0,0 +1,50 @@ +"use strict"; + +const expect = require("chai").expect; +const fill = require("../../../../../../client/js/libs/handlebars/ircmessageparser/fill"); + +describe("fill", () => { + const text = "01234567890123456789"; + + it("should return an entry for the unmatched end of string", () => { + const existingEntries = [ + {start: 0, end: 10}, + {start: 5, end: 15}, + ]; + + const expected = [ + {start: 15, end: 20}, + ]; + + const actual = fill(existingEntries, text); + + expect(actual).to.deep.equal(expected); + }); + + it("should return an entry per unmatched areas of the text", () => { + const existingEntries = [ + {start: 0, end: 5}, + {start: 10, end: 15}, + ]; + + const expected = [ + {start: 5, end: 10}, + {start: 15, end: 20}, + ]; + + const actual = fill(existingEntries, text); + + expect(actual).to.deep.equal(expected); + }); + + it("should not return anything when entries match all text", () => { + const existingEntries = [ + {start: 0, end: 10}, + {start: 10, end: 20}, + ]; + + const actual = fill(existingEntries, text); + + expect(actual).to.be.empty; + }); +}); diff --git a/test/client/js/libs/handlebars/ircmessageparser/findChannels.js b/test/client/js/libs/handlebars/ircmessageparser/findChannels.js new file mode 100644 index 00000000..87663251 --- /dev/null +++ b/test/client/js/libs/handlebars/ircmessageparser/findChannels.js @@ -0,0 +1,123 @@ +"use strict"; + +const expect = require("chai").expect; +const findChannels = require("../../../../../../client/js/libs/handlebars/ircmessageparser/findChannels"); + +describe("findChannels", () => { + it("should find single letter channel", () => { + const input = "#a"; + const expected = [{ + channel: "#a", + start: 0, + end: 2 + }]; + + const actual = findChannels(input, ["#"], ["@", "+"]); + + expect(actual).to.deep.equal(expected); + }); + + it("should find utf8 channels", () => { + const input = "#äöü"; + const expected = [{ + channel: "#äöü", + start: 0, + end: 4 + }]; + + const actual = findChannels(input, ["#"], ["@", "+"]); + + expect(actual).to.deep.equal(expected); + }); + + it("should find inline channel", () => { + const input = "inline #channel text"; + const expected = [{ + channel: "#channel", + start: 7, + end: 15 + }]; + + const actual = findChannels(input, ["#"], ["@", "+"]); + + expect(actual).to.deep.equal(expected); + }); + + it("should stop at \\0x07", () => { + const input = "#chan\x07nel"; + const expected = [{ + channel: "#chan", + start: 0, + end: 5 + }]; + + const actual = findChannels(input, ["#"], ["@", "+"]); + + expect(actual).to.deep.equal(expected); + }); + + it("should allow classics pranks", () => { + const input = "#1,000"; + const expected = [{ + channel: "#1,000", + start: 0, + end: 6 + }]; + + const actual = findChannels(input, ["#"], ["@", "+"]); + + expect(actual).to.deep.equal(expected); + }); + + it("should work with whois responses", () => { + const input = "@#a"; + const expected = [{ + channel: "#a", + start: 1, + end: 3 + }]; + + const actual = findChannels(input, ["#"], ["@", "+"]); + + expect(actual).to.deep.equal(expected); + }); + + it("should work with IRCv3.1 multi-prefix", () => { + const input = "!@%+#a"; + const expected = [{ + channel: "#a", + start: 4, + end: 6 + }]; + + const actual = findChannels(input, ["#"], ["!", "@", "%", "+"]); + + expect(actual).to.deep.equal(expected); + }); + + it("should work with custom channelPrefixes", () => { + const input = "@a"; + const expected = [{ + channel: "@a", + start: 0, + end: 2 + }]; + + const actual = findChannels(input, ["@"], ["#", "+"]); + + expect(actual).to.deep.equal(expected); + }); + + it("should handle multiple channelPrefix correctly", () => { + const input = "##test"; + const expected = [{ + channel: "##test", + start: 0, + end: 6 + }]; + + const actual = findChannels(input, ["#"], ["@", "+"]); + + expect(actual).to.deep.equal(expected); + }); +}); diff --git a/test/client/js/libs/handlebars/ircmessageparser/findEmoji.js b/test/client/js/libs/handlebars/ircmessageparser/findEmoji.js new file mode 100644 index 00000000..1360fa6d --- /dev/null +++ b/test/client/js/libs/handlebars/ircmessageparser/findEmoji.js @@ -0,0 +1,58 @@ +"use strict"; + +const expect = require("chai").expect; +const findEmoji = require("../../../../../../client/js/libs/handlebars/ircmessageparser/findEmoji"); + +describe("findEmoji", () => { + it("should find default emoji presentation character", () => { + const input = "test \u{231A} test"; + const expected = [{ + start: 5, + end: 6, + emoji: "\u{231A}", + }]; + + const actual = findEmoji(input); + + expect(actual).to.deep.equal(expected); + }); + + it("should find default text presentation character rendered as emoji", () => { + const input = "test \u{2194}\u{FE0F} test"; + const expected = [{ + start: 5, + end: 7, + emoji: "\u{2194}\u{FE0F}", + }]; + + const actual = findEmoji(input); + + expect(actual).to.deep.equal(expected); + }); + + it("should find emoji modifier base", () => { + const input = "test\u{1F469}test"; + const expected = [{ + start: 4, + end: 6, + emoji: "\u{1F469}", + }]; + + const actual = findEmoji(input); + + expect(actual).to.deep.equal(expected); + }); + + it("should find emoji modifier base followed by a modifier", () => { + const input = "test\u{1F469}\u{1F3FF}test"; + const expected = [{ + start: 4, + end: 8, + emoji: "\u{1F469}\u{1F3FF}", + }]; + + const actual = findEmoji(input); + + expect(actual).to.deep.equal(expected); + }); +}); diff --git a/test/client/js/libs/handlebars/ircmessageparser/findLinks.js b/test/client/js/libs/handlebars/ircmessageparser/findLinks.js new file mode 100644 index 00000000..f3f228f2 --- /dev/null +++ b/test/client/js/libs/handlebars/ircmessageparser/findLinks.js @@ -0,0 +1,106 @@ +"use strict"; + +const expect = require("chai").expect; +const findLinks = require("../../../../../../client/js/libs/handlebars/ircmessageparser/findLinks"); + +describe("findLinks", () => { + it("should find url", () => { + const input = "irc://freenode.net/thelounge"; + const expected = [{ + start: 0, + end: 28, + link: "irc://freenode.net/thelounge", + }]; + + const actual = findLinks(input); + + expect(actual).to.deep.equal(expected); + }); + + it("should find urls with www", () => { + const input = "www.nooooooooooooooo.com"; + const expected = [{ + start: 0, + end: 24, + link: "http://www.nooooooooooooooo.com" + }]; + + const actual = findLinks(input); + + expect(actual).to.deep.equal(expected); + }); + + it("should find urls in strings", () => { + const input = "look at https://thelounge.github.io/ for more information"; + const expected = [{ + link: "https://thelounge.github.io/", + start: 8, + end: 36 + }]; + + const actual = findLinks(input); + + expect(actual).to.deep.equal(expected); + }); + + it("should find urls in strings starting with www", () => { + const input = "use www.duckduckgo.com for privacy reasons"; + const expected = [{ + link: "http://www.duckduckgo.com", + start: 4, + end: 22 + }]; + + const actual = findLinks(input); + + expect(actual).to.deep.equal(expected); + }); + + it("should find urls with odd surroundings", () => { + const input = ""; + const expected = [{ + link: "https://theos.kyriasis.com/~kyrias/stats/archlinux.html", + start: 1, + end: 56 + }]; + + const actual = findLinks(input); + + expect(actual).to.deep.equal(expected); + }); + + it("should find urls with starting with www. and odd surroundings", () => { + const input = ".:www.github.com:."; + const expected = [{ + link: "http://www.github.com", + start: 2, + end: 16 + }]; + + const actual = findLinks(input); + + expect(actual).to.deep.equal(expected); + }); + + it("should not find urls", () => { + const input = "text www. text"; + const expected = []; + + const actual = findLinks(input); + + expect(actual).to.deep.equal(expected); + }); + + it("should handle multiple www. correctly", () => { + const input = "www.www.test.com"; + const expected = [{ + link: "http://www.www.test.com", + start: 0, + end: 16 + }]; + + const actual = findLinks(input); + + expect(actual).to.deep.equal(expected); + }); +}); diff --git a/test/client/js/libs/handlebars/ircmessageparser/merge.js b/test/client/js/libs/handlebars/ircmessageparser/merge.js new file mode 100644 index 00000000..d55ac1a2 --- /dev/null +++ b/test/client/js/libs/handlebars/ircmessageparser/merge.js @@ -0,0 +1,63 @@ +"use strict"; + +const expect = require("chai").expect; +const merge = require("../../../../../../client/js/libs/handlebars/ircmessageparser/merge"); + +describe("merge", () => { + it("should split style information", () => { + const textParts = [{ + start: 0, + end: 10, + flag1: true + }, { + start: 10, + end: 20, + flag2: true + }]; + const styleFragments = [{ + start: 0, + end: 5, + text: "01234" + }, { + start: 5, + end: 15, + text: "5678901234" + }, { + start: 15, + end: 20, + text: "56789" + }]; + + const expected = [{ + start: 0, + end: 10, + flag1: true, + fragments: [{ + start: 0, + end: 5, + text: "01234" + }, { + start: 5, + end: 10, + text: "56789" + }] + }, { + start: 10, + end: 20, + flag2: true, + fragments: [{ + start: 10, + end: 15, + text: "01234" + }, { + start: 15, + end: 20, + text: "56789" + }] + }]; + + const actual = merge(textParts, styleFragments); + + expect(actual).to.deep.equal(expected); + }); +}); diff --git a/test/client/js/libs/handlebars/ircmessageparser/parseStyle.js b/test/client/js/libs/handlebars/ircmessageparser/parseStyle.js new file mode 100644 index 00000000..1e44fdc8 --- /dev/null +++ b/test/client/js/libs/handlebars/ircmessageparser/parseStyle.js @@ -0,0 +1,393 @@ +"use strict"; + +const expect = require("chai").expect; +const parseStyle = require("../../../../../../client/js/libs/handlebars/ircmessageparser/parseStyle"); + +describe("parseStyle", () => { + it("should skip control codes", () => { + const input = "text\x01with\x04control\x05codes"; + const expected = [{ + bold: false, + textColor: undefined, + bgColor: undefined, + hexColor: undefined, + hexBgColor: undefined, + reverse: false, + italic: false, + underline: false, + text: "textwithcontrolcodes", + + start: 0, + end: 20 + }]; + + const actual = parseStyle(input); + + expect(actual).to.deep.equal(expected); + }); + + it("should parse bold", () => { + const input = "\x02bold"; + const expected = [{ + bold: true, + textColor: undefined, + bgColor: undefined, + hexColor: undefined, + hexBgColor: undefined, + reverse: false, + italic: false, + underline: false, + text: "bold", + + start: 0, + end: 4 + }]; + + const actual = parseStyle(input); + + expect(actual).to.deep.equal(expected); + }); + + it("should parse textColor", () => { + const input = "\x038yellowText"; + const expected = [{ + bold: false, + textColor: 8, + bgColor: undefined, + hexColor: undefined, + hexBgColor: undefined, + reverse: false, + italic: false, + underline: false, + text: "yellowText", + + start: 0, + end: 10 + }]; + + const actual = parseStyle(input); + + expect(actual).to.deep.equal(expected); + }); + + it("should parse textColor and background", () => { + const input = "\x034,8yellowBG redText"; + const expected = [{ + textColor: 4, + bgColor: 8, + hexColor: undefined, + hexBgColor: undefined, + bold: false, + reverse: false, + italic: false, + underline: false, + text: "yellowBG redText", + + start: 0, + end: 16 + }]; + + const actual = parseStyle(input); + + expect(actual).to.deep.equal(expected); + }); + + it("should parse italic", () => { + const input = "\x1ditalic"; + const expected = [{ + bold: false, + textColor: undefined, + bgColor: undefined, + hexColor: undefined, + hexBgColor: undefined, + reverse: false, + italic: true, + underline: false, + text: "italic", + + start: 0, + end: 6 + }]; + + const actual = parseStyle(input); + + expect(actual).to.deep.equal(expected); + }); + + it("should parse hex colors", () => { + const input = "test \x04FFFFFFnice \x02\x04RES006 \x0303,04\x04ff00FFcolored\x04eeeaFF,001122 background\x04\x03\x02?"; + const expected = [{ + bold: false, + textColor: undefined, + bgColor: undefined, + hexColor: undefined, + hexBgColor: undefined, + reverse: false, + italic: false, + underline: false, + text: "test ", + + start: 0, + end: 5 + }, { + bold: false, + textColor: undefined, + bgColor: undefined, + hexColor: "FFFFFF", + hexBgColor: undefined, + reverse: false, + italic: false, + underline: false, + text: "nice ", + + start: 5, + end: 10 + }, { + bold: true, + textColor: undefined, + bgColor: undefined, + hexColor: undefined, + hexBgColor: undefined, + reverse: false, + italic: false, + underline: false, + text: "RES006 ", + + start: 10, + end: 17 + }, { + bold: true, + textColor: 3, + bgColor: 4, + hexColor: "FF00FF", + hexBgColor: undefined, + reverse: false, + italic: false, + underline: false, + text: "colored", + + start: 17, + end: 24 + }, { + bold: true, + textColor: 3, + bgColor: 4, + hexColor: "EEEAFF", + hexBgColor: "001122", + reverse: false, + italic: false, + underline: false, + text: " background", + + start: 24, + end: 35 + }, { + bold: false, + textColor: undefined, + bgColor: undefined, + hexColor: undefined, + hexBgColor: undefined, + reverse: false, + italic: false, + underline: false, + text: "?", + + start: 35, + end: 36 + }]; + + const actual = parseStyle(input); + + expect(actual).to.deep.equal(expected); + }); + + it("should carry state correctly forward", () => { + const input = "\x02bold\x038yellow\x02nonBold\x03default"; + const expected = [{ + bold: true, + textColor: undefined, + bgColor: undefined, + hexColor: undefined, + hexBgColor: undefined, + reverse: false, + italic: false, + underline: false, + text: "bold", + + start: 0, + end: 4 + }, { + bold: true, + textColor: 8, + bgColor: undefined, + hexColor: undefined, + hexBgColor: undefined, + reverse: false, + italic: false, + underline: false, + text: "yellow", + + start: 4, + end: 10 + }, { + bold: false, + textColor: 8, + bgColor: undefined, + hexColor: undefined, + hexBgColor: undefined, + reverse: false, + italic: false, + underline: false, + text: "nonBold", + + start: 10, + end: 17 + }, { + bold: false, + textColor: undefined, + bgColor: undefined, + hexColor: undefined, + hexBgColor: undefined, + reverse: false, + italic: false, + underline: false, + text: "default", + + start: 17, + end: 24 + }]; + + const actual = parseStyle(input); + + expect(actual).to.deep.equal(expected); + }); + + it("should toggle bold correctly", () => { + const input = "\x02bold\x02 \x02bold\x02"; + const expected = [{ + bold: true, + textColor: undefined, + bgColor: undefined, + hexColor: undefined, + hexBgColor: undefined, + reverse: false, + italic: false, + underline: false, + text: "bold", + + start: 0, + end: 4 + }, { + bold: false, + textColor: undefined, + bgColor: undefined, + hexColor: undefined, + hexBgColor: undefined, + reverse: false, + italic: false, + underline: false, + text: " ", + + start: 4, + end: 5 + }, { + bold: true, + textColor: undefined, + bgColor: undefined, + hexColor: undefined, + hexBgColor: undefined, + reverse: false, + italic: false, + underline: false, + text: "bold", + + start: 5, + end: 9 + }]; + + const actual = parseStyle(input); + + expect(actual).to.deep.equal(expected); + }); + + it("should reset all styles", () => { + const input = "\x02\x034\x16\x1d\x1ffull\x0fnone"; + const expected = [{ + bold: true, + textColor: 4, + bgColor: undefined, + hexColor: undefined, + hexBgColor: undefined, + reverse: true, + italic: true, + underline: true, + text: "full", + + start: 0, + end: 4 + }, { + bold: false, + textColor: undefined, + bgColor: undefined, + hexColor: undefined, + hexBgColor: undefined, + reverse: false, + italic: false, + underline: false, + text: "none", + + start: 4, + end: 8 + }]; + + const actual = parseStyle(input); + + expect(actual).to.deep.equal(expected); + }); + + it("should not emit empty fragments", () => { + const input = "\x031\x031,2\x031\x031,2\x031\x031,2\x03a"; + const expected = [{ + bold: false, + textColor: undefined, + bgColor: undefined, + hexColor: undefined, + hexBgColor: undefined, + reverse: false, + italic: false, + underline: false, + text: "a", + + start: 0, + end: 1 + }]; + + const actual = parseStyle(input); + + expect(actual).to.deep.equal(expected); + }); + + it("should optimize fragments", () => { + const rawString = "oh hi test text"; + const colorCode = "\x0312"; + const input = colorCode + rawString.split("").join(colorCode); + const expected = [{ + bold: false, + textColor: 12, + bgColor: undefined, + hexColor: undefined, + hexBgColor: undefined, + reverse: false, + italic: false, + underline: false, + text: rawString, + + start: 0, + end: rawString.length + }]; + + const actual = parseStyle(input); + + expect(actual).to.deep.equal(expected); + }); +}); diff --git a/test/client/js/libs/handlebars/localetimeTest.js b/test/client/js/libs/handlebars/localetimeTest.js index 75debd8e..f5de1d51 100644 --- a/test/client/js/libs/handlebars/localetimeTest.js +++ b/test/client/js/libs/handlebars/localetimeTest.js @@ -4,10 +4,9 @@ const expect = require("chai").expect; const localetime = require("../../../../../client/js/libs/handlebars/localetime"); describe("localetime Handlebars helper", () => { - it("should render a human-readable date", () => { // 12PM in UTC time - const date = new Date("2014-05-22T12:00:00"); + const date = new Date("2014-05-22T12:00:00Z"); // Offset between UTC and local timezone const offset = date.getTimezoneOffset() * 60 * 1000; @@ -15,7 +14,6 @@ describe("localetime Handlebars helper", () => { // Pretend local timezone is UTC by moving the clock of that offset const time = date.getTime() + offset; - expect(localetime(time)).to.equal("5/22/2014, 12:00:00 PM"); + expect(localetime(time)).to.equal("22 May 2014, 12:00:00"); }); - }); diff --git a/test/client/js/libs/handlebars/parse.js b/test/client/js/libs/handlebars/parse.js new file mode 100644 index 00000000..700d5fee --- /dev/null +++ b/test/client/js/libs/handlebars/parse.js @@ -0,0 +1,336 @@ +"use strict"; + +const expect = require("chai").expect; +const parse = require("../../../../../client/js/libs/handlebars/parse"); + +describe("parse Handlebars helper", () => { + it("should not introduce xss", () => { + const testCases = [{ + input: "", + expected: "<img onerror='location.href="//youtube.com"'>" + }, { + input: "#&\">bug", + expected: "#&">bug" + }]; + + const actual = testCases.map((testCase) => parse(testCase.input)); + const expected = testCases.map((testCase) => testCase.expected); + + expect(actual).to.deep.equal(expected); + }); + + it("should skip control codes", () => { + const testCases = [{ + input: "text\x01with\x04control\x05codes", + expected: "textwithcontrolcodes" + }]; + + const actual = testCases.map((testCase) => parse(testCase.input)); + const expected = testCases.map((testCase) => testCase.expected); + + expect(actual).to.deep.equal(expected); + }); + + it("should find urls", () => { + const testCases = [{ + input: "irc://freenode.net/thelounge", + expected: + "" + + "irc://freenode.net/thelounge" + + "" + }, { + input: "www.nooooooooooooooo.com", + expected: + "" + + "www.nooooooooooooooo.com" + + "" + }, { + input: "look at https://thelounge.github.io/ for more information", + expected: + "look at " + + "" + + "https://thelounge.github.io/" + + "" + + " for more information" + }, { + input: "use www.duckduckgo.com for privacy reasons", + expected: + "use " + + "" + + "www.duckduckgo.com" + + "" + + " for privacy reasons" + }, { + input: "svn+ssh://example.org", + expected: + "" + + "svn+ssh://example.org" + + "" + }]; + + const actual = testCases.map((testCase) => parse(testCase.input)); + const expected = testCases.map((testCase) => testCase.expected); + + expect(actual).to.deep.equal(expected); + }); + + it("url with a dot parsed correctly", () => { + const input = + "bonuspunkt: your URL parser misparses this URL: https://msdn.microsoft.com/en-us/library/windows/desktop/ms644989(v=vs.85).aspx"; + const correctResult = + "bonuspunkt: your URL parser misparses this URL: " + + "" + + "https://msdn.microsoft.com/en-us/library/windows/desktop/ms644989(v=vs.85).aspx" + + ""; + + const actual = parse(input); + + expect(actual).to.deep.equal(correctResult); + }); + + it("should balance brackets", () => { + const testCases = [{ + input: "", + expected: + "<" + + "" + + "https://theos.kyriasis.com/~kyrias/stats/archlinux.html" + + "" + + ">" + }, { + input: "abc (www.example.com)", + expected: + "abc (" + + "" + + "www.example.com" + + "" + + ")" + }, { + input: "http://example.com/Test_(Page)", + expected: + "" + + "http://example.com/Test_(Page)" + + "" + }, { + input: "www.example.com/Test_(Page)", + expected: + "" + + "www.example.com/Test_(Page)" + + "" + }]; + + const actual = testCases.map((testCase) => parse(testCase.input)); + const expected = testCases.map((testCase) => testCase.expected); + + expect(actual).to.deep.equal(expected); + }); + + it("should not find urls", () => { + const testCases = [{ + input: "text www. text", + expected: "text www. text" + }, { + input: "http://.", + expected: "http://." + }]; + + const actual = testCases.map((testCase) => parse(testCase.input)); + const expected = testCases.map((testCase) => testCase.expected); + + expect(actual).to.deep.equal(expected); + }); + + it("should find channels", () => { + const testCases = [{ + input: "#a", + expected: + "" + + "#a" + + "" + }, { + input: "#test", + expected: + "" + + "#test" + + "" + }, { + input: "#äöü", + expected: + "" + + "#äöü" + + "" + }, { + input: "inline #channel text", + expected: + "inline " + + "" + + "#channel" + + "" + + " text" + }, { + input: "#1,000", + expected: + "" + + "#1,000" + + "" + }, { + input: "@#a", + expected: + "@" + + "" + + "#a" + + "" + }]; + + const actual = testCases.map((testCase) => parse(testCase.input)); + const expected = testCases.map((testCase) => testCase.expected); + + expect(actual).to.deep.equal(expected); + }); + + it("should not find channels", () => { + const testCases = [{ + input: "hi#test", + expected: "hi#test" + }, { + input: "#", + expected: "#" + }]; + + const actual = testCases.map((testCase) => parse(testCase.input)); + const expected = testCases.map((testCase) => testCase.expected); + + expect(actual).to.deep.equal(expected); + }); + + it("should style like mirc", () => { + const testCases = [{ + input: "\x02bold", + expected: "bold" + }, { + input: "\x038yellowText", + expected: "yellowText" + }, { + input: "\x030,0white,white", + expected: "white,white" + }, { + input: "\x034,8yellowBGredText", + expected: "yellowBGredText" + }, { + input: "\x1ditalic", + expected: "italic" + }, { + input: "\x1funderline", + expected: "underline" + }, { + input: "\x02bold\x038yellow\x02nonBold\x03default", + expected: + "bold" + + "yellow" + + "nonBold" + + "default" + }, { + input: "\x02bold\x02 \x02bold\x02", + expected: + "bold" + + " " + + "bold" + }]; + + const actual = testCases.map((testCase) => parse(testCase.input)); + const expected = testCases.map((testCase) => testCase.expected); + + expect(actual).to.deep.equal(expected); + }); + + it("should go bonkers like mirc", () => { + const testCases = [{ + input: "\x02irc\x0f://\x1dfreenode.net\x0f/\x034,8thelounge", + expected: + "" + + "irc" + + "://" + + "freenode.net" + + "/" + + "thelounge" + + "" + }, { + input: "\x02#\x038,9thelounge", + expected: + "" + + "#" + + "thelounge" + + "" + }]; + + const actual = testCases.map((testCase) => parse(testCase.input)); + const expected = testCases.map((testCase) => testCase.expected); + + expect(actual).to.deep.equal(expected); + }); + + it("should optimize generated html", () => { + const testCases = [{ + input: "test \x0312#\x0312\x0312\"te\x0312st\x0312\x0312\x0312\x0312\x0312\x0312\x0312\x0312\x0312\x0312\x0312a", + expected: + "test " + + "" + + "#"testa" + + "" + }]; + + const actual = testCases.map((testCase) => parse(testCase.input)); + const expected = testCases.map((testCase) => testCase.expected); + + expect(actual).to.deep.equal(expected); + }); + + it("should trim common protocols", () => { + const testCases = [{ + input: "like..http://example.com", + expected: + "like.." + + "" + + "http://example.com" + + "" + }, { + input: "like..HTTP://example.com", + expected: + "like.." + + "" + + "HTTP://example.com" + + "" + }]; + + const actual = testCases.map((testCase) => parse(testCase.input)); + const expected = testCases.map((testCase) => testCase.expected); + + expect(actual).to.deep.equal(expected); + }); + + it("should not find channel in fragment", () => { + const testCases = [{ + input: "http://example.com/#hash", + expected: + "" + + "" + + "http://example.com/#hash" + + "" + }]; + + const actual = testCases.map((testCase) => parse(testCase.input)); + const expected = testCases.map((testCase) => testCase.expected); + + expect(actual).to.deep.equal(expected); + }); + + it("should not overlap parts", () => { + const input = "Url: http://example.com/path Channel: ##channel"; + const actual = parse(input); + + expect(actual).to.equal( + "Url: http://example.com/path " + + "Channel: ##channel" + ); + }); +}); diff --git a/test/fixtures/.gitignore b/test/fixtures/.gitignore new file mode 100644 index 00000000..f58e9cac --- /dev/null +++ b/test/fixtures/.gitignore @@ -0,0 +1,3 @@ +# files that may be generated by tests +.lounge/storage/ +.lounge/vapid.json diff --git a/test/fixtures/.lounge/config.js b/test/fixtures/.lounge/config.js index c04fbb18..cbc7b926 100644 --- a/test/fixtures/.lounge/config.js +++ b/test/fixtures/.lounge/config.js @@ -2,6 +2,7 @@ var config = require("../../../defaults/config.js"); +config.public = true; config.prefetch = true; config.host = config.bind = "127.0.0.1"; config.port = 61337; diff --git a/test/models/chan.js b/test/models/chan.js index 1a051182..fa0482ae 100644 --- a/test/models/chan.js +++ b/test/models/chan.js @@ -3,9 +3,30 @@ var expect = require("chai").expect; var Chan = require("../../src/models/chan"); +var Msg = require("../../src/models/msg"); var User = require("../../src/models/user"); describe("Chan", function() { + describe("#findMessage(id)", function() { + const chan = new Chan({ + messages: [ + new Msg(), + new Msg({ + text: "Message to be found" + }), + new Msg() + ] + }); + + it("should find a message in the list of messages", function() { + expect(chan.findMessage(1).text).to.equal("Message to be found"); + }); + + it("should not find a message that does not exist", function() { + expect(chan.findMessage(42)).to.be.undefined; + }); + }); + describe("#sortUsers(irc)", function() { var network = { network: { @@ -23,7 +44,7 @@ describe("Chan", function() { var prefixLookup = {}; - network.network.options.PREFIX.forEach(mode => { + network.network.options.PREFIX.forEach((mode) => { prefixLookup[mode.mode] = mode.symbol; }); @@ -32,9 +53,7 @@ describe("Chan", function() { }; var getUserNames = function(chan) { - return chan.users.map(function(u) { - return u.name; - }); + return chan.users.map((u) => u.nick); }; it("should sort a simple user list", function() { diff --git a/test/models/msg.js b/test/models/msg.js new file mode 100644 index 00000000..5a9a8ad8 --- /dev/null +++ b/test/models/msg.js @@ -0,0 +1,37 @@ +"use strict"; + +const expect = require("chai").expect; + +const Msg = require("../../src/models/msg"); + +describe("Msg", function() { + describe("#findPreview(link)", function() { + const msg = new Msg({ + previews: [{ + body: "", + head: "Example Domain", + link: "https://example.org/", + thumb: "", + type: "link", + shown: true, + }, { + body: "", + head: "The Lounge", + link: "https://thelounge.github.io/", + thumb: "", + type: "link", + shown: true, + }] + }); + + it("should find a preview given an existing link", function() { + expect(msg.findPreview("https://thelounge.github.io/").head) + .to.equal("The Lounge"); + }); + + it("should not find a preview that does not exist", function() { + expect(msg.findPreview("https://github.com/thelounge/lounge")) + .to.be.undefined; + }); + }); +}); diff --git a/test/models/network.js b/test/models/network.js index fe56157d..1727580c 100644 --- a/test/models/network.js +++ b/test/models/network.js @@ -8,13 +8,15 @@ var Network = require("../../src/models/network"); describe("Network", function() { describe("#export()", function() { - it("should produce an valid object", function() { var network = new Network({ + awayMessage: "I am away", name: "networkName", channels: [ - new Chan({name: "#thelounge"}), - new Chan({name: "&foobar"}), + new Chan({name: "#thelounge", key: ""}), + new Chan({name: "&foobar", key: ""}), + new Chan({name: "#secret", key: "foo"}), + new Chan({name: "&secure", key: "bar"}), new Chan({name: "Channel List", type: Chan.Type.SPECIAL}), new Chan({name: "PrivateChat", type: Chan.Type.QUERY}), ] @@ -22,6 +24,7 @@ describe("Network", function() { network.setNick("chillin`"); expect(network.export()).to.deep.equal({ + awayMessage: "I am away", name: "networkName", host: "", port: 6667, @@ -34,8 +37,10 @@ describe("Network", function() { ip: null, hostname: null, channels: [ - {name: "#thelounge"}, - {name: "&foobar"}, + {name: "#thelounge", key: ""}, + {name: "&foobar", key: ""}, + {name: "#secret", key: "foo"}, + {name: "&secure", key: "bar"}, ] }); }); diff --git a/test/plugins/link.js b/test/plugins/link.js index 4eab22f1..dfd73b0f 100644 --- a/test/plugins/link.js +++ b/test/plugins/link.js @@ -1,13 +1,17 @@ "use strict"; -var assert = require("assert"); - -var util = require("../util"); -var link = require("../../src/plugins/irc-events/link.js"); +const path = require("path"); +const expect = require("chai").expect; +const util = require("../util"); +const Helper = require("../../src/helper"); +const link = require("../../src/plugins/irc-events/link.js"); describe("Link plugin", function() { before(function(done) { this.app = util.createWebserver(); + this.app.get("/real-test-image.png", function(req, res) { + res.sendFile(path.resolve(__dirname, "../../client/img/apple-touch-icon-120x120.png")); + }); this.connection = this.app.listen(9002, done); }); @@ -18,22 +22,246 @@ describe("Link plugin", function() { beforeEach(function() { this.irc = util.createClient(); this.network = util.createNetwork(); + + Helper.config.prefetchStorage = false; }); it("should be able to fetch basic information about URLs", function(done) { - let message = this.irc.createMessage({ - text: "http://localhost:9002/basic" + const url = "http://localhost:9002/basic"; + const message = this.irc.createMessage({ + text: url }); link(this.irc, this.network.channels[0], message); + expect(message.previews).to.deep.equal([{ + body: "", + head: "", + link: url, + thumb: "", + type: "loading", + shown: true, + }]); + this.app.get("/basic", function(req, res) { - res.send("test"); + res.send("test title"); }); - this.irc.once("toggle", function(data) { - assert.equal(data.head, "test"); + this.irc.once("msg:preview", function(data) { + expect(data.preview.type).to.equal("link"); + expect(data.preview.head).to.equal("test title"); + expect(data.preview.body).to.equal("simple description"); + expect(data.preview.link).to.equal(url); + + expect(message.previews).to.deep.equal([data.preview]); done(); }); }); + + it("should prefer og:title over title", function(done) { + const message = this.irc.createMessage({ + text: "http://localhost:9002/basic-og" + }); + + link(this.irc, this.network.channels[0], message); + + this.app.get("/basic-og", function(req, res) { + res.send("test"); + }); + + this.irc.once("msg:preview", function(data) { + expect(data.preview.head, "opengraph test"); + done(); + }); + }); + + it("should prefer og:description over description", function(done) { + const message = this.irc.createMessage({ + text: "http://localhost:9002/description-og" + }); + + link(this.irc, this.network.channels[0], message); + + this.app.get("/description-og", function(req, res) { + res.send(""); + }); + + this.irc.once("msg:preview", function(data) { + expect(data.preview.body).to.equal("opengraph description"); + done(); + }); + }); + + it("should find og:image with full url", function(done) { + const message = this.irc.createMessage({ + text: "http://localhost:9002/thumb" + }); + + link(this.irc, this.network.channels[0], message); + + this.app.get("/thumb", function(req, res) { + res.send("Google"); + }); + + this.irc.once("msg:preview", function(data) { + expect(data.preview.head).to.equal("Google"); + expect(data.preview.thumb).to.equal("http://localhost:9002/real-test-image.png"); + done(); + }); + }); + + it("should find image_src", function(done) { + const message = this.irc.createMessage({ + text: "http://localhost:9002/thumb-image-src" + }); + + link(this.irc, this.network.channels[0], message); + + this.app.get("/thumb-image-src", function(req, res) { + res.send(""); + }); + + this.irc.once("msg:preview", function(data) { + expect(data.preview.thumb).to.equal("http://localhost:9002/real-test-image.png"); + done(); + }); + }); + + it("should correctly resolve relative protocol", function(done) { + const message = this.irc.createMessage({ + text: "http://localhost:9002/thumb-image-src" + }); + + link(this.irc, this.network.channels[0], message); + + this.app.get("/thumb-image-src", function(req, res) { + res.send(""); + }); + + this.irc.once("msg:preview", function(data) { + expect(data.preview.thumb).to.equal("http://localhost:9002/real-test-image.png"); + done(); + }); + }); + + it("should resolve url correctly for relative url", function(done) { + const message = this.irc.createMessage({ + text: "http://localhost:9002/relative-thumb" + }); + + link(this.irc, this.network.channels[0], message); + + this.app.get("/relative-thumb", function(req, res) { + res.send("test relative image"); + }); + + this.irc.once("msg:preview", function(data) { + expect(data.preview.thumb).to.equal("http://localhost:9002/real-test-image.png"); + expect(data.preview.head).to.equal("test relative image"); + expect(data.preview.link).to.equal("http://localhost:9002/relative-thumb"); + done(); + }); + }); + + it("should send untitled page if there is a thumbnail", function(done) { + const message = this.irc.createMessage({ + text: "http://localhost:9002/thumb-no-title" + }); + + link(this.irc, this.network.channels[0], message); + + this.app.get("/thumb-no-title", function(req, res) { + res.send(""); + }); + + this.irc.once("msg:preview", function(data) { + expect(data.preview.head).to.equal("Untitled page"); + expect(data.preview.thumb).to.equal("http://localhost:9002/real-test-image.png"); + expect(data.preview.link).to.equal("http://localhost:9002/thumb-no-title"); + done(); + }); + }); + + it("should not send thumbnail if image is 404", function(done) { + const message = this.irc.createMessage({ + text: "http://localhost:9002/thumb-404" + }); + + link(this.irc, this.network.channels[0], message); + + this.app.get("/thumb-404", function(req, res) { + res.send("404 image"); + }); + + this.irc.once("msg:preview", function(data) { + expect(data.preview.head).to.equal("404 image"); + expect(data.preview.link).to.equal("http://localhost:9002/thumb-404"); + expect(data.preview.thumb).to.be.empty; + done(); + }); + }); + + it("should send image preview", function(done) { + const message = this.irc.createMessage({ + text: "http://localhost:9002/real-test-image.png" + }); + + link(this.irc, this.network.channels[0], message); + + this.irc.once("msg:preview", function(data) { + expect(data.preview.type).to.equal("image"); + expect(data.preview.link).to.equal("http://localhost:9002/real-test-image.png"); + expect(data.preview.thumb).to.equal("http://localhost:9002/real-test-image.png"); + done(); + }); + }); + + it("should load multiple URLs found in messages", function(done) { + const message = this.irc.createMessage({ + text: "http://localhost:9002/one http://localhost:9002/two" + }); + + link(this.irc, this.network.channels[0], message); + + expect(message.previews).to.eql([{ + body: "", + head: "", + link: "http://localhost:9002/one", + thumb: "", + type: "loading", + shown: true, + }, { + body: "", + head: "", + link: "http://localhost:9002/two", + thumb: "", + type: "loading", + shown: true, + }]); + + this.app.get("/one", function(req, res) { + res.send("first title"); + }); + + this.app.get("/two", function(req, res) { + res.send("second title"); + }); + + const previews = []; + + this.irc.on("msg:preview", function(data) { + if (data.preview.link === "http://localhost:9002/one") { + expect(data.preview.head).to.equal("first title"); + previews[0] = data.preview; + } else if (data.preview.link === "http://localhost:9002/two") { + expect(data.preview.head).to.equal("second title"); + previews[1] = data.preview; + } + + if (previews[0] && previews[1]) { + expect(message.previews).to.eql(previews); + done(); + } + }); + }); }); diff --git a/test/plugins/storage.js b/test/plugins/storage.js new file mode 100644 index 00000000..e372fe01 --- /dev/null +++ b/test/plugins/storage.js @@ -0,0 +1,68 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const crypto = require("crypto"); +const expect = require("chai").expect; +const util = require("../util"); +const Helper = require("../../src/helper"); +const link = require("../../src/plugins/irc-events/link.js"); + +describe("Image storage", function() { + const testImagePath = path.resolve(__dirname, "../../client/img/apple-touch-icon-120x120.png"); + const correctImageHash = crypto.createHash("sha256").update(fs.readFileSync(testImagePath)).digest("hex"); + const correctImageURL = `storage/${correctImageHash.substring(0, 2)}/${correctImageHash.substring(2, 4)}/${correctImageHash.substring(4)}.png`; + + before(function(done) { + this.app = util.createWebserver(); + this.app.get("/real-test-image.png", function(req, res) { + res.sendFile(testImagePath); + }); + this.connection = this.app.listen(9003, done); + }); + + after(function(done) { + this.connection.close(done); + }); + + beforeEach(function() { + this.irc = util.createClient(); + this.network = util.createNetwork(); + + Helper.config.prefetchStorage = true; + }); + + it("should store the thumbnail", function(done) { + const message = this.irc.createMessage({ + text: "http://localhost:9003/thumb" + }); + + link(this.irc, this.network.channels[0], message); + + this.app.get("/thumb", function(req, res) { + res.send("Google"); + }); + + this.irc.once("msg:preview", function(data) { + expect(data.preview.head).to.equal("Google"); + expect(data.preview.link).to.equal("http://localhost:9003/thumb"); + expect(data.preview.thumb).to.equal(correctImageURL); + done(); + }); + }); + + it("should store the image", function(done) { + const message = this.irc.createMessage({ + text: "http://localhost:9003/real-test-image.png" + }); + + link(this.irc, this.network.channels[0], message); + + this.irc.once("msg:preview", function(data) { + expect(data.preview.type).to.equal("image"); + expect(data.preview.link).to.equal("http://localhost:9003/real-test-image.png"); + expect(data.preview.thumb).to.equal(correctImageURL); + done(); + }); + }); +}); diff --git a/test/server.js b/test/server.js index 0b1a3388..4d06f980 100644 --- a/test/server.js +++ b/test/server.js @@ -12,7 +12,7 @@ describe("Server", () => { const webURL = `http://${Helper.config.host}:${Helper.config.port}/`; describe("Express", () => { - it("should run a web server on " + webURL, done => { + it("should run a web server on " + webURL, (done) => { request(webURL, (error, response, body) => { expect(error).to.be.null; expect(body).to.include("The Lounge"); @@ -22,7 +22,7 @@ describe("Server", () => { }); }); - it("should serve static content correctly", done => { + it("should serve static content correctly", (done) => { request(webURL + "manifest.json", (error, response, body) => { expect(error).to.be.null; @@ -58,11 +58,11 @@ describe("Server", () => { client.close(); }); - it("should emit authorized message", done => { + it("should emit authorized message", (done) => { client.on("authorized", done); }); - it("should create network", done => { + it("should create network", (done) => { client.on("init", () => { client.emit("conn", { username: "test-user", @@ -75,7 +75,7 @@ describe("Server", () => { }); }); - client.on("network", data => { + client.on("network", (data) => { expect(data.networks).to.be.an("array"); expect(data.networks).to.have.lengthOf(1); expect(data.networks[0].realname).to.equal("The Lounge Test"); @@ -86,8 +86,8 @@ describe("Server", () => { }); }); - it("should emit init message", done => { - client.on("init", data => { + it("should emit init message", (done) => { + client.on("init", (data) => { expect(data.active).to.equal(-1); expect(data.networks).to.be.an("array"); expect(data.networks).to.be.empty; diff --git a/test/tests/cleanircmessages.js b/test/tests/cleanircmessages.js new file mode 100644 index 00000000..6a3877c6 --- /dev/null +++ b/test/tests/cleanircmessages.js @@ -0,0 +1,48 @@ +"use strict"; + +const expect = require("chai").expect; +const Helper = require("../../src/helper"); + +describe("Clean IRC messages", function() { + it("should remove all formatting", function() { + const testCases = [{ + input: "\x0303", + expected: "" + }, { + input: "\x02bold", + expected: "bold" + }, { + input: "\x038yellowText", + expected: "yellowText" + }, { + input: "\x030,0white,white", + expected: "white,white" + }, { + input: "\x034,8yellowBGredText", + expected: "yellowBGredText" + }, { + input: "\x1ditalic", + expected: "italic" + }, { + input: "\x1funderline", + expected: "underline" + }, { + input: "\x02bold\x038yellow\x02nonBold\x03default", + expected: "boldyellownonBolddefault" + }, { + input: "\x02bold\x02 \x02bold\x02", + expected: "bold bold" + }, { + input: "\x02irc\x0f://\x1dfreenode.net\x0f/\x034,8thelounge", + expected: "irc://freenode.net/thelounge" + }, { + input: "\x02#\x038,9thelounge", + expected: "#thelounge" + }]; + + const actual = testCases.map((testCase) => Helper.cleanIrcMessage(testCase.input)); + const expected = testCases.map((testCase) => testCase.expected); + + expect(actual).to.deep.equal(expected); + }); +}); diff --git a/test/tests/passwords.js b/test/tests/passwords.js index d074477d..a5c1fa96 100644 --- a/test/tests/passwords.js +++ b/test/tests/passwords.js @@ -8,16 +8,29 @@ describe("Client passwords", function() { it("hashed password should match", function() { // Generated with third party tool to test implementation - let comparedPassword = Helper.password.compare(inputPassword, "$2a$11$zrPPcfZ091WNfs6QrRHtQeUitlgrJcecfZhxOFiQs0FWw7TN3Q1oS"); + const comparedPassword = Helper.password.compare(inputPassword, "$2a$11$zrPPcfZ091WNfs6QrRHtQeUitlgrJcecfZhxOFiQs0FWw7TN3Q1oS"); - expect(comparedPassword).to.be.true; + return comparedPassword.then((result) => { + expect(result).to.be.true; + }); + }); + + it("wrong hashed password should not match", function() { + // Compare against a fake hash + const comparedPassword = Helper.password.compare(inputPassword, "$2a$11$zrPPcfZ091WRONGPASSWORDitlgrJcecfZhxOFiQs0FWw7TN3Q1oS"); + + return comparedPassword.then((result) => { + expect(result).to.be.false; + }); }); it("freshly hashed password should match", function() { - let hashedPassword = Helper.password.hash(inputPassword); - let comparedPassword = Helper.password.compare(inputPassword, hashedPassword); + const hashedPassword = Helper.password.hash(inputPassword); + const comparedPassword = Helper.password.compare(inputPassword, hashedPassword); - expect(comparedPassword).to.be.true; + return comparedPassword.then((result) => { + expect(result).to.be.true; + }); }); it("shout passwords should be marked as old", function() { diff --git a/test/util.js b/test/util.js index 88e3b409..bee4c3af 100644 --- a/test/util.js +++ b/test/util.js @@ -20,7 +20,8 @@ MockClient.prototype.createMessage = function(opts) { var message = _.extend({ text: "dummy message", nick: "test-user", - target: "#test-channel" + target: "#test-channel", + previews: [], }, opts); return message; diff --git a/webpack.config.js b/webpack.config.js index 09c36561..e1888588 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -7,17 +7,9 @@ const path = require("path"); // Common configuration // ******************** -let config = { +const config = { entry: { "js/bundle.js": path.resolve(__dirname, "client/js/lounge.js"), - "js/bundle.vendor.js": [ - "handlebars/runtime", - "jquery", - "jquery-ui/ui/widgets/sortable", - "mousetrap", - "socket.io-client", - "urijs", - ], }, devtool: "source-map", output: { @@ -36,7 +28,11 @@ let config = { loader: "babel-loader", options: { presets: [ - "es2015" + ["env", { + targets: { + browsers: "last 2 versions" + } + }] ] } } @@ -60,8 +56,17 @@ let config = { }, ] }, + externals: { + json3: "JSON", // socket.io uses json3.js, but we do not target any browsers that need it + }, plugins: [ - new webpack.optimize.CommonsChunkPlugin("js/bundle.vendor.js") + // socket.io uses debug, we don't need it + new webpack.NormalModuleReplacementPlugin(/debug/, path.resolve(__dirname, "scripts/noop.js")), + // automatically split all vendor dependencies into a separate bundle + new webpack.optimize.CommonsChunkPlugin({ + name: "js/bundle.vendor.js", + minChunks: (module) => module.context && module.context.indexOf("node_modules") !== -1 + }) ] }; @@ -71,6 +76,7 @@ let config = { if (process.env.NODE_ENV === "production") { config.plugins.push(new webpack.optimize.UglifyJsPlugin({ + sourceMap: true, comments: false })); } else {