Merge remote-tracking branch 'origin/master' into richrd/message-search

This commit is contained in:
Mary Strodl 2021-01-26 16:43:56 -05:00 committed by Nachtalb
commit 283ef445e5
No known key found for this signature in database
GPG key ID: E48DF13C07055D92
89 changed files with 4745 additions and 4962 deletions

View file

@ -55,9 +55,16 @@ rules:
spaced-comment: [error, always] spaced-comment: [error, always]
strict: off strict: off
yoda: error yoda: error
vue/require-default-prop: off vue/component-tags-order:
- error
- order:
- template
- style
- script
vue/no-mutating-props: off
vue/no-v-html: off vue/no-v-html: off
vue/no-use-v-if-with-v-for: off vue/require-default-prop: off
vue/v-slot-style: [error, longform]
plugins: plugins:
- vue - vue

16
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View file

@ -0,0 +1,16 @@
contact_links:
- name: Docker container issues
url: https://github.com/thelounge/thelounge-docker/issues
about: Report issues related to the Docker container here
- name: Debian package issues
url: https://github.com/thelounge/thelounge-deb/issues
about: Report issues related to the Debian package here
- name: Arch Linux package issues
url: https://github.com/thelounge/thelounge-archlinux/issues
about: Report issues related to the Arch Linux package here
- name: General support
url: https://demo.thelounge.chat/?join=%23thelounge
about: "Join #thelounge on Freenode to ask a question before creating an issue"

View file

@ -8,22 +8,34 @@ jobs:
strategy: strategy:
matrix: matrix:
os: [ubuntu-latest, windows-latest, macOS-latest] include:
node_version: [ # EOL: April 2021
10.x, # EOL: April 2021 - os: ubuntu-latest
12.x, # EOL: April 2022
]
exclude:
- os: macOS-latest
node_version: 10.x node_version: 10.x
# EOL: April 2022
- os: ubuntu-latest
node_version: 12.x
# EOL: April 2023
- os: ubuntu-latest
node_version: 14.x
- os: macOS-latest
node_version: 14.x
- os: windows-latest
node_version: 14.x
# EOL: June 2021
- os: ubuntu-latest
node_version: 15.x
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- uses: actions/checkout@master - uses: actions/checkout@master
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v1 uses: actions/setup-node@v2
with: with:
node-version: ${{ matrix.node_version }} node-version: ${{ matrix.node_version }}

View file

@ -14,7 +14,7 @@ jobs:
- uses: actions/checkout@master - uses: actions/checkout@master
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v1 uses: actions/setup-node@v2
with: with:
registry-url: "https://registry.npmjs.org/" registry-url: "https://registry.npmjs.org/"

View file

@ -1,8 +1,5 @@
extends: stylelint-config-standard extends: stylelint-config-standard
ignoreFiles:
- client/css/bootstrap.css
rules: rules:
indentation: tab indentation: tab
# complains about FontAwesome # complains about FontAwesome

9
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,9 @@
{
"recommendations": [
"EditorConfig.EditorConfig",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"octref.vetur"
],
"unwantedRecommendations": []
}

11
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,11 @@
{
"configurations": [
{
"type": "node-terminal",
"name": "Run Dev",
"request": "launch",
"command": "yarn dev",
"cwd": "${workspaceFolder}"
}
]
}

10
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,10 @@
{
"editor.formatOnSave": true,
"vetur.format.enable": false,
"prettier.useEditorConfig": true,
"prettier.requireConfig": true,
"prettier.disableLanguages": [],
"prettier.packageManager": "yarn",
"eslint.packageManager": "yarn",
"eslint.codeActionsOnSave.mode": "all"
}

View file

@ -4,6 +4,148 @@ All notable changes to this project will be documented in this file.
<!-- New entries go after this line --> <!-- New entries go after this line -->
## v4.3.0-pre.1 - 2021-03-02 [Pre-release]
[See the full changelog](https://github.com/thelounge/thelounge/compare/v4.2.0...v4.3.0-pre.1)
This is a pre-release for v4.3.0 to offer latest changes without having to wait for a stable release.
At this stage, features may still be added or modified until the first release candidate for this version gets released.
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
yarn global add thelounge@next
```
## v4.2.0 - 2020-08-19
For more details, [see the full changelog](https://github.com/thelounge/thelounge/compare/v4.1.0...v4.2.0) and [milestone](https://github.com/thelounge/thelounge/milestone/36?closed=1).
This is a minor release with one significant new feature: a mentions panel!
<p align="center">
<img width="466" alt="Mentions panel" src="https://user-images.githubusercontent.com/613331/90796491-0fadf380-e318-11ea-8fda-51613a9a3221.png">
</p>
Other notable additions include custom highlight exceptions, a new configuration option to not send preview requests to 3rd party websites, and uploaded images will have [EXIF](https://en.wikipedia.org/wiki/Exif) data automatically removed.
There's also a new section for configuring SASL on the Connect screen, and `SASL EXTERNAL` is now supported.
<p align="center">
<img width="489" alt="SASL authentication" src="https://user-images.githubusercontent.com/613331/90796501-15a3d480-e318-11ea-9dab-c225816a6685.png">
<img width="474" alt="SASL external (certfp)" src="https://user-images.githubusercontent.com/613331/90796504-15a3d480-e318-11ea-9636-c1025c9d2306.png">
</p>
Along with other bugs, a Chrome bug causing lag when typing has been fixed. Additionally, the `node-sqlite3` dependency has been updated, and you no longer need to re-install The Lounge when you update Node.js.
And as an update for our Docker users, `thelounge-docker` now has support for ARM images; thanks [@williamboman](https://github.com/williamboman) and [@klausenbusk](https://github.com/klausenbusk)!
### Added
- Track mentions/highlights and add a window to view them ([#3858](https://github.com/thelounge/thelounge/pull/3858), [#3993](https://github.com/thelounge/thelounge/pull/3993), [#3862](https://github.com/thelounge/thelounge/pull/3862), [#3868](https://github.com/thelounge/thelounge/pull/3868), [#4003](https://github.com/thelounge/thelounge/pull/4003) by [@xPaw](https://github.com/xPaw))
- Add an option to display 12-hour times ([#3787](https://github.com/thelounge/thelounge/pull/3787) by [@xPaw](https://github.com/xPaw))
- Add clear channel history (available in channel context menu)([#3778](https://github.com/thelounge/thelounge/pull/3778) by [@xPaw](https://github.com/xPaw))
- Add CertFP support; separate SASL configuration; merge `displayNetwork` and `lockNetwork` in The Lounge configuration file ([#3844](https://github.com/thelounge/thelounge/pull/3844) by [@xPaw](https://github.com/xPaw))
- Add an indicator to `STATUSMSG` messages ([#3875](https://github.com/thelounge/thelounge/pull/3875) by [@xPaw](https://github.com/xPaw))
- Add native app badges for highlights (Chrome 81+) ([#3845](https://github.com/thelounge/thelounge/pull/3845) by [@xPaw](https://github.com/xPaw))
- Add generic monospace blocks for `INFO` and `HELP` numerics ([#3962](https://github.com/thelounge/thelounge/pull/3962) by [@xPaw](https://github.com/xPaw), [#4032](https://github.com/thelounge/thelounge/pull/4032) by [@xPaw](https://github.com/xPaw))
- Add option to disable media preview ([#3983](https://github.com/thelounge/thelounge/pull/3983) by [@dalcde](https://github.com/dalcde))
- Add custom highlight exceptions ([#3998](https://github.com/thelounge/thelounge/pull/3998) by [@Jay2k1](https://github.com/Jay2k1))
- Add navigation in image viewer ([#3798](https://github.com/thelounge/thelounge/pull/3798) by [@richrd](https://github.com/richrd))
- Render images in canvas before upload to remove EXIF data ([#3764](https://github.com/thelounge/thelounge/pull/3764) by [@xPaw](https://github.com/xPaw))
### Changed
- Disable link prefetching for urls with no schema specified ([#4014](https://github.com/thelounge/thelounge/pull/4014) by [@xPaw](https://github.com/xPaw))
- Disable settings sync for browser notifications and notification sound ([#4028](https://github.com/thelounge/thelounge/pull/4028) by [@xPaw](https://github.com/xPaw))
- Make usernames case-insensitive when logging in ([#3918](https://github.com/thelounge/thelounge/pull/3918) by [@ashwinikammar](https://github.com/ashwinikammar))
- Separate active sessions section ([#3817](https://github.com/thelounge/thelounge/pull/3817) by [@xPaw](https://github.com/xPaw))
- Add `role=group` to status messages setting ([#3790](https://github.com/thelounge/thelounge/pull/3790) by [@xPaw](https://github.com/xPaw))
- Filter user loading at startup for "advanced" LDAP ([#3871](https://github.com/thelounge/thelounge/pull/3871) by [@ebardie](https://github.com/ebardie))
- Reconnects now use exponential backoff
- Update production dependencies to their latest versions:
- `uuid` ([#3791](https://github.com/thelounge/thelounge/pull/3791), [#3837](https://github.com/thelounge/thelounge/pull/3837), [#3890](https://github.com/thelounge/thelounge/pull/3890), [#3919](https://github.com/thelounge/thelounge/pull/3919), [#3957](https://github.com/thelounge/thelounge/pull/3957), [#4004](https://github.com/thelounge/thelounge/pull/4004))
- `yarn` ([#3792](https://github.com/thelounge/thelounge/pull/3792), [#3800](https://github.com/thelounge/thelounge/pull/3800))
- `file-type` ([#3801](https://github.com/thelounge/thelounge/pull/3801), [#3896](https://github.com/thelounge/thelounge/pull/3896), [#3909](https://github.com/thelounge/thelounge/pull/3909), [#3920](https://github.com/thelounge/thelounge/pull/3920), [#3934](https://github.com/thelounge/thelounge/pull/3934), [#3940](https://github.com/thelounge/thelounge/pull/3940))
- `commander` ([#3807](https://github.com/thelounge/thelounge/pull/3807), [#3992](https://github.com/thelounge/thelounge/pull/3992))
- `got` ([#3829](https://github.com/thelounge/thelounge/pull/3829), [#3869](https://github.com/thelounge/thelounge/pull/3869), [#3898](https://github.com/thelounge/thelounge/pull/3898), [#3905](https://github.com/thelounge/thelounge/pull/3905), [#3932](https://github.com/thelounge/thelounge/pull/3932), [#3935](https://github.com/thelounge/thelounge/pull/3935), [#3972](https://github.com/thelounge/thelounge/pull/3972), [#3988](https://github.com/thelounge/thelounge/pull/3988))
- `irc-framework` ([#3838](https://github.com/thelounge/thelounge/pull/3838), [#3984](https://github.com/thelounge/thelounge/pull/3984))
- `chalk` ([#3839](https://github.com/thelounge/thelounge/pull/3839))
- `semver` ([#3843](https://github.com/thelounge/thelounge/pull/3843), [#3863](https://github.com/thelounge/thelounge/pull/3863))
- `web-push` ([#3904](https://github.com/thelounge/thelounge/pull/3904))
- `linkify-it` ([#3917](https://github.com/thelounge/thelounge/pull/3917))
- `sqlite3` ([#3886](https://github.com/thelounge/thelounge/pull/3886))
- `ldapjs` ([#3931](https://github.com/thelounge/thelounge/pull/3931), [#3996](https://github.com/thelounge/thelounge/pull/3996))
- `tlds` ([#4015](https://github.com/thelounge/thelounge/pull/4015))
### Fixed
- Fix sending unhandled numerics to target channel ([#3789](https://github.com/thelounge/thelounge/pull/3789) by [@xPaw](https://github.com/xPaw))
- Fix up first argument not being used as part message ([#3808](https://github.com/thelounge/thelounge/pull/3808) by [@xPaw](https://github.com/xPaw))
- Pass in client manager object in update checker ([#3797](https://github.com/thelounge/thelounge/pull/3797) by [@xPaw](https://github.com/xPaw))
- Do not handle navigation keybinds in inputs if not empty ([#3814](https://github.com/thelounge/thelounge/pull/3814) by [@xPaw](https://github.com/xPaw))
- Fix body overscroll and overflow on iOS Safari ([#3828](https://github.com/thelounge/thelounge/pull/3828) by [@stevenengler](https://github.com/stevenengler))
- Fix off-by-one color error in webmanifest ([#3867](https://github.com/thelounge/thelounge/pull/3867) by [@maxpoulin64](https://github.com/maxpoulin64))
- Support multiple arguments in eventbus emit ([#3885](https://github.com/thelounge/thelounge/pull/3885) by [@xPaw](https://github.com/xPaw))
- Fix msg id order when loading from sqlite ([#3888](https://github.com/thelounge/thelounge/pull/3888) by [@xPaw](https://github.com/xPaw))
- Reply to the server if that's where CTCP VERSION originated ([#3906](https://github.com/thelounge/thelounge/pull/3906) by [@xPaw](https://github.com/xPaw))
- Fix date marker not displaying sometimes ([#3978](https://github.com/thelounge/thelounge/pull/3978) by [@xPaw](https://github.com/xPaw))
- Allow changing network name in private mode with lockNetwork ([#3977](https://github.com/thelounge/thelounge/pull/3977) by [@xPaw](https://github.com/xPaw))
- Fix upload tokens expiring while uploading when TL is proxied ([#3986](https://github.com/thelounge/thelounge/pull/3986) by [@xPaw](https://github.com/xPaw))
- Refresh notification permission state when push is enabled ([#3987](https://github.com/thelounge/thelounge/pull/3987) by [@xPaw](https://github.com/xPaw))
- Fix mode message only making last nick clickable ([#4005](https://github.com/thelounge/thelounge/pull/4005) by [@xPaw](https://github.com/xPaw))
- Sync changed network name to open clients ([#4038](https://github.com/thelounge/thelounge/pull/4038) by [@xPaw](https://github.com/xPaw))
- Fix layout trashing in Chrome causing typing lag ([#3999](https://github.com/thelounge/thelounge/pull/3999) by [@xPaw](https://github.com/xPaw))
- Fixed a rare bug in `irc-framework` that caused duplicate messages
### Internals
- Optimize user list updates for quit/part/kick events ([#3857](https://github.com/thelounge/thelounge/pull/3857) by [@xPaw](https://github.com/xPaw))
- Remove "The Lounge" from connect in public ([#3816](https://github.com/thelounge/thelounge/pull/3816) by [@xPaw](https://github.com/xPaw))
- Replace all uses of `fs-extra` with native methods ([#3810](https://github.com/thelounge/thelounge/pull/3810) by [@xPaw](https://github.com/xPaw))
- Upgrade to `mocha@7` and remove `mochapack` ([#3826](https://github.com/thelounge/thelounge/pull/3826) by [@xPaw](https://github.com/xPaw))
- Remove `intersection-observer` polyfill ([#3864](https://github.com/thelounge/thelounge/pull/3864) by [@xPaw](https://github.com/xPaw))
- Safeguard nick randomizer up to allowed length ([#3870](https://github.com/thelounge/thelounge/pull/3870) by [@xPaw](https://github.com/xPaw))
- Replace vue events with our own event bus ([#3872](https://github.com/thelounge/thelounge/pull/3872) by [@xPaw](https://github.com/xPaw))
- Cleanup vue router route guards ([#3995](https://github.com/thelounge/thelounge/pull/3995) by [@xPaw](https://github.com/xPaw))
- Use lodash where possible ([#4020](https://github.com/thelounge/thelounge/pull/4020) by [@xPaw](https://github.com/xPaw))
- Replace dashes to underscores in emoji autocompletion ([#4029](https://github.com/thelounge/thelounge/pull/4029) by [@xPaw](https://github.com/xPaw))
- Changes required for vue 3 ([#3889](https://github.com/thelounge/thelounge/pull/3889) by [@timmw](https://github.com/timmw))
- Test node v14 ([#3976](https://github.com/thelounge/thelounge/pull/3976) by [@xPaw](https://github.com/xPaw))
- Update development dependencies to their latest versions.
## v4.2.0-pre.2 - 2020-07-28 [Pre-release]
[See the full changelog](https://github.com/thelounge/thelounge/compare/v4.2.0-pre.1...v4.2.0-pre.2)
This is a pre-release for v4.2.0 to offer latest changes without having to wait for a stable release.
At this stage, features may still be added or modified until the first release candidate for this version gets released.
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
yarn global add thelounge@next
```
## v4.2.0-pre.1 - 2020-05-17 [Pre-release]
[See the full changelog](https://github.com/thelounge/thelounge/compare/v4.1.0...v4.2.0-pre.1)
This is a pre-release for v4.2.0 to offer latest changes without having to wait for a stable release.
At this stage, features may still be added or modified until the first release candidate for this version gets released.
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
yarn global add thelounge@next
```
## v4.1.0 - 2020-03-09 ## v4.1.0 - 2020-03-09
For more details, [see the full changelog](https://github.com/thelounge/thelounge/compare/v4.0.0...v4.1.0) and [milestone](https://github.com/thelounge/thelounge/milestone/35?closed=1). For more details, [see the full changelog](https://github.com/thelounge/thelounge/compare/v4.0.0...v4.1.0) and [milestone](https://github.com/thelounge/thelounge/milestone/35?closed=1).

View file

@ -85,5 +85,5 @@ Before submitting any change, make sure to:
- Read the [Contributing instructions](https://github.com/thelounge/thelounge/blob/master/.github/CONTRIBUTING.md#contributing) - Read the [Contributing instructions](https://github.com/thelounge/thelounge/blob/master/.github/CONTRIBUTING.md#contributing)
- Run `yarn test` to execute linters and test suite - Run `yarn test` to execute linters and test suite
- Run `yarn build` if you change or add anything in `client/js` or `client/views` - Run `yarn build` if you change or add anything in `client/js` or `client/components`
- `yarn dev` can be used to start The Lounge with hot module reloading - `yarn dev` can be used to start The Lounge with hot module reloading

View file

@ -13,6 +13,7 @@
<script> <script>
const constants = require("../js/constants"); const constants = require("../js/constants");
import eventbus from "../js/eventbus";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import throttle from "lodash/throttle"; import throttle from "lodash/throttle";
import storage from "../js/localStorage"; import storage from "../js/localStorage";
@ -53,14 +54,14 @@ export default {
// Make a single throttled resize listener available to all components // Make a single throttled resize listener available to all components
this.debouncedResize = throttle(() => { this.debouncedResize = throttle(() => {
this.$root.$emit("resize"); eventbus.emit("resize");
}, 100); }, 100);
window.addEventListener("resize", this.debouncedResize, {passive: true}); window.addEventListener("resize", this.debouncedResize, {passive: true});
// Emit a daychange event every time the day changes so date markers know when to update themselves // Emit a daychange event every time the day changes so date markers know when to update themselves
const emitDayChange = () => { const emitDayChange = () => {
this.$root.$emit("daychange"); eventbus.emit("daychange");
// This should always be 24h later but re-computing exact value just in case // This should always be 24h later but re-computing exact value just in case
this.dayChangeTimeout = setTimeout(emitDayChange, this.msUntilNextDay()); this.dayChangeTimeout = setTimeout(emitDayChange, this.msUntilNextDay());
}; };
@ -77,7 +78,7 @@ export default {
}, },
methods: { methods: {
escapeKey() { escapeKey() {
this.$root.$emit("escapekey"); eventbus.emit("escapekey");
}, },
toggleSidebar(e) { toggleSidebar(e) {
if (isIgnoredKeybind(e)) { if (isIgnoredKeybind(e)) {

View file

@ -30,6 +30,7 @@
</template> </template>
<script> <script>
import eventbus from "../js/eventbus";
import isChannelCollapsed from "../js/helpers/isChannelCollapsed"; import isChannelCollapsed from "../js/helpers/isChannelCollapsed";
export default { export default {
@ -74,7 +75,7 @@ export default {
this.$root.switchToChannel(this.channel); this.$root.switchToChannel(this.channel);
}, },
openContextMenu(event) { openContextMenu(event) {
this.$root.$emit("contextmenu:channel", { eventbus.emit("contextmenu:channel", {
event: event, event: event,
channel: this.channel, channel: this.channel,
network: this.network, network: this.network,

View file

@ -1,5 +1,5 @@
<template> <template>
<div id="chat-container" class="window" :data-current-channel="channel.name"> <div id="chat-container" class="window" :data-current-channel="channel.name" lang="">
<div <div
id="chat" id="chat"
:class="{ :class="{
@ -25,6 +25,7 @@
:value="channel.topic" :value="channel.topic"
class="topic-input" class="topic-input"
placeholder="Set channel topic" placeholder="Set channel topic"
enterkeyhint="done"
@keyup.enter="saveTopic" @keyup.enter="saveTopic"
@keyup.esc="channel.editTopic = false" @keyup.esc="channel.editTopic = false"
/> />
@ -69,7 +70,7 @@
<div class="chat"> <div class="chat">
<div class="messages"> <div class="messages">
<div class="msg"> <div class="msg">
<Component <component
:is="specialComponent" :is="specialComponent"
:network="network" :network="network"
:channel="channel" :channel="channel"
@ -107,6 +108,7 @@
<script> <script>
import socket from "../js/socket"; import socket from "../js/socket";
import eventbus from "../js/eventbus";
import ParsedMessage from "./ParsedMessage.vue"; import ParsedMessage from "./ParsedMessage.vue";
import MessageList from "./MessageList.vue"; import MessageList from "./MessageList.vue";
import ChatInput from "./ChatInput.vue"; import ChatInput from "./ChatInput.vue";
@ -204,14 +206,14 @@ export default {
} }
}, },
openContextMenu(event) { openContextMenu(event) {
this.$root.$emit("contextmenu:channel", { eventbus.emit("contextmenu:channel", {
event: event, event: event,
channel: this.channel, channel: this.channel,
network: this.network, network: this.network,
}); });
}, },
openMentions() { openMentions() {
this.$root.$emit("mentions:toggle", { eventbus.emit("mentions:toggle", {
event: event, event: event,
}); });
}, },

View file

@ -7,6 +7,7 @@
ref="input" ref="input"
dir="auto" dir="auto"
class="mousetrap" class="mousetrap"
enterkeyhint="send"
:value="channel.pendingMessage" :value="channel.pendingMessage"
:placeholder="getInputPlaceholder(channel)" :placeholder="getInputPlaceholder(channel)"
:aria-label="getInputPlaceholder(channel)" :aria-label="getInputPlaceholder(channel)"
@ -24,6 +25,7 @@
id="upload-input" id="upload-input"
ref="uploadInput" ref="uploadInput"
type="file" type="file"
aria-labelledby="upload"
multiple multiple
@change="onUploadInputChange" @change="onUploadInputChange"
/> />
@ -56,6 +58,7 @@ import autocompletion from "../js/autocompletion";
import commands from "../js/commands/index"; import commands from "../js/commands/index";
import socket from "../js/socket"; import socket from "../js/socket";
import upload from "../js/upload"; import upload from "../js/upload";
import eventbus from "../js/eventbus";
const formattingHotkeys = { const formattingHotkeys = {
"mod+k": "\x03", "mod+k": "\x03",
@ -101,7 +104,7 @@ export default {
}, },
}, },
mounted() { mounted() {
this.$root.$on("escapekey", this.blurInput); eventbus.on("escapekey", this.blurInput);
if (this.$store.state.settings.autocomplete) { if (this.$store.state.settings.autocomplete) {
autocompletionRef = autocompletion(this.$refs.input); autocompletionRef = autocompletion(this.$refs.input);
@ -163,7 +166,7 @@ export default {
} }
}, },
destroyed() { destroyed() {
this.$root.$off("escapekey", this.blurInput); eventbus.off("escapekey", this.blurInput);
if (autocompletionRef) { if (autocompletionRef) {
autocompletionRef.destroy(); autocompletionRef.destroy();

View file

@ -32,7 +32,7 @@
:on-hover="hoverUser" :on-hover="hoverUser"
:active="user.original === activeUser" :active="user.original === activeUser"
:user="user.original" :user="user.original"
v-html="user.original.mode + user.string" v-html="user.string"
/> />
</template> </template>
<template v-else> <template v-else>
@ -98,18 +98,25 @@ export default {
const result = this.filteredUsers; const result = this.filteredUsers;
for (const user of result) { for (const user of result) {
if (!groups[user.original.mode]) { const mode = user.original.modes[0] || "";
groups[user.original.mode] = [];
if (!groups[mode]) {
groups[mode] = [];
} }
groups[user.original.mode].push(user); // Prepend user mode to search result
user.string = mode + user.string;
groups[mode].push(user);
} }
} else { } else {
for (const user of this.channel.users) { for (const user of this.channel.users) {
if (!groups[user.mode]) { const mode = user.modes[0] || "";
groups[user.mode] = [user];
if (!groups[mode]) {
groups[mode] = [user];
} else { } else {
groups[user.mode].push(user); groups[mode].push(user);
} }
} }
} }

View file

@ -51,6 +51,8 @@
</style> </style>
<script> <script>
import eventbus from "../js/eventbus";
export default { export default {
name: "ConfirmDialog", name: "ConfirmDialog",
data() { data() {
@ -60,12 +62,12 @@ export default {
}; };
}, },
mounted() { mounted() {
this.$root.$on("escapekey", this.close); eventbus.on("escapekey", this.close);
this.$root.$on("confirm-dialog", this.open); eventbus.on("confirm-dialog", this.open);
}, },
destroyed() { destroyed() {
this.$root.$off("escapekey", this.close); eventbus.off("escapekey", this.close);
this.$root.$off("confirm-dialog", this.open); eventbus.off("confirm-dialog", this.open);
}, },
methods: { methods: {
open(data, callback) { open(data, callback) {

View file

@ -39,6 +39,7 @@
<script> <script>
import {generateUserContextMenu, generateChannelContextMenu} from "../js/helpers/contextMenu.js"; import {generateUserContextMenu, generateChannelContextMenu} from "../js/helpers/contextMenu.js";
import eventbus from "../js/eventbus";
export default { export default {
name: "ContextMenu", name: "ContextMenu",
@ -58,14 +59,14 @@ export default {
}; };
}, },
mounted() { mounted() {
this.$root.$on("escapekey", this.close); eventbus.on("escapekey", this.close);
this.$root.$on("contextmenu:user", this.openUserContextMenu); eventbus.on("contextmenu:user", this.openUserContextMenu);
this.$root.$on("contextmenu:channel", this.openChannelContextMenu); eventbus.on("contextmenu:channel", this.openChannelContextMenu);
}, },
destroyed() { destroyed() {
this.$root.$off("escapekey", this.close); eventbus.off("escapekey", this.close);
this.$root.$off("contextmenu:user", this.openUserContextMenu); eventbus.off("contextmenu:user", this.openUserContextMenu);
this.$root.$off("contextmenu:channel", this.openChannelContextMenu); eventbus.off("contextmenu:channel", this.openChannelContextMenu);
this.close(); this.close();
}, },
@ -75,19 +76,17 @@ export default {
this.open(data.event, items); this.open(data.event, items);
}, },
openUserContextMenu(data) { openUserContextMenu(data) {
const activeChannel = this.$store.state.activeChannel; const {network, channel} = this.$store.state.activeChannel;
// If there's an active network and channel use them
let {network, channel} = activeChannel ? activeChannel : {network: null, channel: null};
// Use network and channel from event if specified const items = generateUserContextMenu(
network = data.network ? data.network : network; this.$root,
channel = data.channel ? data.channel : channel; channel,
network,
const defaultUser = {nick: data.user.nick}; channel.users.find((u) => u.nick === data.user.nick) || {
let user = channel ? channel.users.find((u) => u.nick === data.user.nick) : defaultUser; nick: data.user.nick,
user = user ? user : defaultUser; modes: [],
}
const items = generateUserContextMenu(this.$root, channel, network, user); );
this.open(data.event, items); this.open(data.event, items);
}, },
open(event, items) { open(event, items) {

View file

@ -9,6 +9,7 @@
<script> <script>
import dayjs from "dayjs"; import dayjs from "dayjs";
import calendar from "dayjs/plugin/calendar"; import calendar from "dayjs/plugin/calendar";
import eventbus from "../js/eventbus";
dayjs.extend(calendar); dayjs.extend(calendar);
@ -24,11 +25,11 @@ export default {
}, },
mounted() { mounted() {
if (this.hoursPassed() < 48) { if (this.hoursPassed() < 48) {
this.$root.$on("daychange", this.dayChange); eventbus.on("daychange", this.dayChange);
} }
}, },
beforeDestroy() { beforeDestroy() {
this.$root.$off("daychange", this.dayChange); eventbus.off("daychange", this.dayChange);
}, },
methods: { methods: {
hoursPassed() { hoursPassed() {
@ -38,7 +39,7 @@ export default {
this.$forceUpdate(); this.$forceUpdate();
if (this.hoursPassed() >= 48) { if (this.hoursPassed() >= 48) {
this.$root.$off("daychange", this.dayChange); eventbus.off("daychange", this.dayChange);
} }
}, },
friendlyDate() { friendlyDate() {

View file

@ -40,6 +40,7 @@
<script> <script>
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import eventbus from "../js/eventbus";
export default { export default {
name: "ImageViewer", name: "ImageViewer",
@ -79,8 +80,8 @@ export default {
link(newLink, oldLink) { link(newLink, oldLink) {
// TODO: history.pushState // TODO: history.pushState
if (newLink === null) { if (newLink === null) {
this.$root.$off("escapekey", this.closeViewer); eventbus.off("escapekey", this.closeViewer);
this.$root.$off("resize", this.correctPosition); eventbus.off("resize", this.correctPosition);
Mousetrap.unbind("left", this.previous); Mousetrap.unbind("left", this.previous);
Mousetrap.unbind("right", this.next); Mousetrap.unbind("right", this.next);
return; return;
@ -89,8 +90,8 @@ export default {
this.setPrevNextImages(); this.setPrevNextImages();
if (!oldLink) { if (!oldLink) {
this.$root.$on("escapekey", this.closeViewer); eventbus.on("escapekey", this.closeViewer);
this.$root.$on("resize", this.correctPosition); eventbus.on("resize", this.correctPosition);
Mousetrap.bind("left", this.previous); Mousetrap.bind("left", this.previous);
Mousetrap.bind("right", this.next); Mousetrap.bind("right", this.next);
} }

View file

@ -5,7 +5,7 @@
method="post" method="post"
action="" action=""
autocomplete="off" autocomplete="off"
@keydown.esc.prevent="$emit('toggleJoinChannel')" @keydown.esc.prevent="$emit('toggle-join-channel')"
@submit.prevent="onSubmit" @submit.prevent="onSubmit"
> >
<input <input
@ -81,7 +81,7 @@ export default {
this.inputChannel = ""; this.inputChannel = "";
this.inputPassword = ""; this.inputPassword = "";
this.$emit("toggleJoinChannel"); this.$emit("toggle-join-channel");
}, },
}, },
}; };

View file

@ -130,6 +130,7 @@
</template> </template>
<script> <script>
import eventbus from "../js/eventbus";
import friendlysize from "../js/helpers/friendlysize"; import friendlysize from "../js/helpers/friendlysize";
export default { export default {
@ -167,12 +168,12 @@ export default {
this.updateShownState(); this.updateShownState();
}, },
mounted() { mounted() {
this.$root.$on("resize", this.handleResize); eventbus.on("resize", this.handleResize);
this.onPreviewUpdate(); this.onPreviewUpdate();
}, },
beforeDestroy() { beforeDestroy() {
this.$root.$off("resize", this.handleResize); eventbus.off("resize", this.handleResize);
}, },
destroyed() { destroyed() {
// Let this preview go through load/canplay events again, // Let this preview go through load/canplay events again,

View file

@ -22,7 +22,7 @@ export default {
onClick() { onClick() {
this.link.shown = !this.link.shown; this.link.shown = !this.link.shown;
this.$parent.$emit("linkPreviewToggle", this.link, this.$parent.message); this.$parent.$emit("toggle-link-preview", this.link, this.$parent.message);
}, },
}, },
}; };

View file

@ -3,36 +3,49 @@
v-if="isOpen" v-if="isOpen"
id="mentions-popup-container" id="mentions-popup-container"
@click="containerClick" @click="containerClick"
@contextmenu.prevent="containerClick" @contextmenu="containerClick"
> >
<div class="mentions-popup"> <div class="mentions-popup">
<div class="mentions-popup-title"> <div class="mentions-popup-title">
Recent mentions Recent mentions
<button
v-if="resolvedMessages.length"
class="btn hide-all-mentions"
@click="hideAllMentions()"
>
Hide all
</button>
</div> </div>
<template v-if="resolvedMessages.length === 0"> <template v-if="resolvedMessages.length === 0">
<p v-if="isLoading">Loading</p> <p v-if="isLoading">Loading</p>
<p v-else>There are no recent mentions.</p> <p v-else>You have no recent mentions.</p>
</template> </template>
<template v-for="message in resolvedMessages" v-else> <template v-for="message in resolvedMessages" v-else>
<div :key="message.id" :class="['msg', message.type]"> <div :key="message.msgId" :class="['msg', message.type]">
<span class="from"> <div class="mentions-info">
<Username :user="message.from" /> <div>
<template v-if="message.channel"> <span class="from">
in {{ message.channel.channel.name }} on <Username :user="message.from" />
{{ message.channel.network.name }} <template v-if="message.channel">
</template> in {{ message.channel.channel.name }} on
<template v-else> {{ message.channel.network.name }}
in unknown channel </template>
</template> <template v-else> in unknown channel </template>
</span> </span>
<span :title="message.time | localetime" class="time"> <span :title="message.localetime" class="time">
{{ messageTime(message.time) }} {{ messageTime(message.time) }}
</span> </span>
<button </div>
class="msg-hide" <div>
aria-label="Hide this mention" <span class="close-tooltip tooltipped tooltipped-w" aria-label="Close">
@click="hideMention(message)" <button
></button> class="msg-hide"
aria-label="Hide this mention"
@click="hideMention(message)"
></button>
</span>
</div>
</div>
<div class="content" dir="auto"> <div class="content" dir="auto">
<ParsedMessage :network="null" :message="message" /> <ParsedMessage :network="null" :message="message" />
</div> </div>
@ -54,16 +67,23 @@
right: 80px; right: 80px;
top: 55px; top: 55px;
max-height: 400px; max-height: 400px;
overflow-y: scroll; overflow-y: auto;
z-index: 2; z-index: 2;
padding: 10px; padding: 10px;
} }
.mentions-popup > .mentions-popup-title { .mentions-popup > .mentions-popup-title {
display: flex;
justify-content: space-between;
margin-bottom: 10px; margin-bottom: 10px;
font-size: 20px; font-size: 20px;
} }
.mentions-popup .mentions-info {
display: flex;
justify-content: space-between;
}
.mentions-popup .msg { .mentions-popup .msg {
margin-bottom: 15px; margin-bottom: 15px;
user-select: text; user-select: text;
@ -78,6 +98,8 @@
border-radius: 5px; border-radius: 5px;
padding: 6px; padding: 6px;
margin-top: 2px; margin-top: 2px;
word-wrap: break-word;
word-break: break-word; /* Webkit-specific */
} }
.mentions-popup .msg-hide::before { .mentions-popup .msg-hide::before {
@ -89,6 +111,21 @@
content: "×"; content: "×";
} }
.mentions-popup .msg-hide:hover {
color: var(--link-color);
}
.mentions-popup .hide-all-mentions {
margin: 0;
padding: 4px 6px;
}
@media (min-height: 500px) {
.mentions-popup {
max-height: 60vh;
}
}
@media (max-width: 768px) { @media (max-width: 768px) {
.mentions-popup { .mentions-popup {
border-radius: 0; border-radius: 0;
@ -108,6 +145,8 @@
import Username from "./Username.vue"; import Username from "./Username.vue";
import ParsedMessage from "./ParsedMessage.vue"; import ParsedMessage from "./ParsedMessage.vue";
import socket from "../js/socket"; import socket from "../js/socket";
import eventbus from "../js/eventbus";
import localetime from "../js/helpers/localetime";
import dayjs from "dayjs"; import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime"; import relativeTime from "dayjs/plugin/relativeTime";
@ -130,6 +169,7 @@ export default {
const messages = this.$store.state.mentions.slice().reverse(); const messages = this.$store.state.mentions.slice().reverse();
for (const message of messages) { for (const message of messages) {
message.localetime = localetime(message.time);
message.channel = this.$store.getters.findChannel(message.chanId); message.channel = this.$store.getters.findChannel(message.chanId);
} }
@ -142,10 +182,10 @@ export default {
}, },
}, },
mounted() { mounted() {
this.$root.$on("mentions:toggle", this.openPopup); eventbus.on("mentions:toggle", this.openPopup);
}, },
destroyed() { destroyed() {
this.$root.$off("mentions:toggle", this.openPopup); eventbus.off("mentions:toggle", this.openPopup);
}, },
methods: { methods: {
messageTime(time) { messageTime(time) {
@ -159,6 +199,10 @@ export default {
socket.emit("mentions:hide", message.msgId); socket.emit("mentions:hide", message.msgId);
}, },
hideAllMentions() {
this.$store.state.mentions = [];
socket.emit("mentions:hide_all");
},
containerClick(event) { containerClick(event) {
if (event.currentTarget === event.target) { if (event.currentTarget === event.target) {
this.isOpen = false; this.isOpen = false;

View file

@ -6,6 +6,7 @@
{self: message.self, highlight: message.highlight, 'previous-source': isPreviousSource}, {self: message.self, highlight: message.highlight, 'previous-source': isPreviousSource},
]" ]"
:data-type="message.type" :data-type="message.type"
:data-command="message.command"
:data-from="message.from && message.from.nick" :data-from="message.from && message.from.nick"
> >
<span :aria-label="messageTimeLocale" class="time tooltipped tooltipped-e" <span :aria-label="messageTimeLocale" class="time tooltipped tooltipped-e"
@ -19,7 +20,7 @@
</template> </template>
<template v-else-if="isAction()"> <template v-else-if="isAction()">
<span class="from"><span class="only-copy">*** </span></span> <span class="from"><span class="only-copy">*** </span></span>
<Component :is="messageComponent" :network="network" :message="message" /> <component :is="messageComponent" :network="network" :message="message" />
</template> </template>
<template v-else-if="message.type === 'action'"> <template v-else-if="message.type === 'action'">
<span class="from"><span class="only-copy">* </span></span> <span class="from"><span class="only-copy">* </span></span>
@ -68,6 +69,12 @@
class="msg-shown-in-active tooltipped tooltipped-e" class="msg-shown-in-active tooltipped tooltipped-e"
><span></span ><span></span
></span> ></span>
<span
v-if="message.statusmsgGroup"
:aria-label="`This message was only shown to users with ${message.statusmsgGroup} mode`"
class="msg-statusmsg tooltipped tooltipped-e"
><span>{{ message.statusmsgGroup }}</span></span
>
<ParsedMessage :network="network" :message="message" /> <ParsedMessage :network="network" :message="message" />
<LinkPreview <LinkPreview
v-for="preview in message.previews" v-for="preview in message.previews"

View file

@ -47,7 +47,7 @@
:message="message" :message="message"
:keep-scroll-position="keepScrollPosition" :keep-scroll-position="keepScrollPosition"
:is-previous-source="isPreviousSource(message, id)" :is-previous-source="isPreviousSource(message, id)"
@linkPreviewToggle="onLinkPreviewToggle" @toggle-link-preview="onLinkPreviewToggle"
/> />
</template> </template>
</div> </div>
@ -56,12 +56,15 @@
<script> <script>
const constants = require("../js/constants"); const constants = require("../js/constants");
import eventbus from "../js/eventbus";
import clipboard from "../js/clipboard"; import clipboard from "../js/clipboard";
import socket from "../js/socket"; import socket from "../js/socket";
import Message from "./Message.vue"; import Message from "./Message.vue";
import MessageCondensed from "./MessageCondensed.vue"; import MessageCondensed from "./MessageCondensed.vue";
import DateMarker from "./DateMarker.vue"; import DateMarker from "./DateMarker.vue";
let unreadMarkerShown = false;
export default { export default {
name: "MessageList", name: "MessageList",
components: { components: {
@ -173,7 +176,7 @@ export default {
mounted() { mounted() {
this.$refs.chat.addEventListener("scroll", this.handleScroll, {passive: true}); this.$refs.chat.addEventListener("scroll", this.handleScroll, {passive: true});
this.$root.$on("resize", this.handleResize); eventbus.on("resize", this.handleResize);
this.$nextTick(() => { this.$nextTick(() => {
if (this.historyObserver) { if (this.historyObserver) {
@ -182,10 +185,10 @@ export default {
}); });
}, },
beforeUpdate() { beforeUpdate() {
this.unreadMarkerShown = false; unreadMarkerShown = false;
}, },
beforeDestroy() { beforeDestroy() {
this.$root.$off("resize", this.handleResize); eventbus.off("resize", this.handleResize);
this.$refs.chat.removeEventListener("scroll", this.handleScroll); this.$refs.chat.removeEventListener("scroll", this.handleScroll);
}, },
destroyed() { destroyed() {
@ -201,11 +204,18 @@ export default {
return true; return true;
} }
return new Date(previousMessage.time).getDay() !== new Date(message.time).getDay(); const oldDate = new Date(previousMessage.time);
const newDate = new Date(message.time);
return (
oldDate.getDate() !== newDate.getDate() ||
oldDate.getMonth() !== newDate.getMonth() ||
oldDate.getFullYear() !== newDate.getFullYear()
);
}, },
shouldDisplayUnreadMarker(id) { shouldDisplayUnreadMarker(id) {
if (!this.unreadMarkerShown && id > this.channel.firstUnread) { if (!unreadMarkerShown && id > this.channel.firstUnread) {
this.unreadMarkerShown = true; unreadMarkerShown = true;
return true; return true;
} }

View file

@ -2,6 +2,12 @@
<span class="content"> <span class="content">
<Username :user="message.from" /> <Username :user="message.from" />
<i class="hostmask"> ({{ message.hostmask }})</i> <i class="hostmask"> ({{ message.hostmask }})</i>
<template v-if="message.account">
<i class="account"> [{{ message.account }}]</i>
</template>
<template v-if="message.gecos">
<i class="realname"> {{ message.gecos }}</i>
</template>
has joined the channel has joined the channel
</span> </span>
</template> </template>

View file

@ -8,7 +8,7 @@
import ParsedMessage from "../ParsedMessage.vue"; import ParsedMessage from "../ParsedMessage.vue";
export default { export default {
name: "MessageTypeMOTD", name: "MessageTypeMonospaceBlock",
components: { components: {
ParsedMessage, ParsedMessage,
}, },

View file

@ -81,6 +81,11 @@
<dd>Yes</dd> <dd>Yes</dd>
</template> </template>
<template v-if="message.whois.certfp">
<dt>Certificate:</dt>
<dd>{{ message.whois.certfp }}</dd>
</template>
<template v-if="message.whois.server"> <template v-if="message.whois.server">
<dt>Connected to:</dt> <dt>Connected to:</dt>
<dd> <dd>

View file

@ -11,7 +11,9 @@
</template> </template>
<template v-else> <template v-else>
Connect Connect
<template v-if="config.lockNetwork">to {{ defaults.name }}</template> <template v-if="config.lockNetwork && $store.state.serverConfiguration.public">
to {{ defaults.name }}
</template>
</template> </template>
</h1> </h1>
<template v-if="!config.lockNetwork"> <template v-if="!config.lockNetwork">
@ -97,6 +99,36 @@
</div> </div>
</div> </div>
</template> </template>
<template v-else-if="config.lockNetwork && !$store.state.serverConfiguration.public">
<h2>Network settings</h2>
<div class="connect-row">
<label for="connect:name">Name</label>
<input
id="connect:name"
v-model="defaults.name"
class="input"
name="name"
maxlength="100"
/>
</div>
<div class="connect-row">
<label for="connect:password">Password</label>
<RevealPassword
v-slot:default="slotProps"
class="input-wrap password-container"
>
<input
id="connect:password"
v-model="defaults.password"
class="input"
:type="slotProps.isVisible ? 'text' : 'password'"
placeholder="Server password (optional)"
name="password"
maxlength="300"
/>
</RevealPassword>
</div>
</template>
<h2>User preferences</h2> <h2>User preferences</h2>
<div class="connect-row"> <div class="connect-row">
@ -135,6 +167,16 @@
maxlength="300" maxlength="300"
/> />
</div> </div>
<div class="connect-row">
<label for="connect:leaveMessage">Leave message</label>
<input
id="connect:leaveMessage"
v-model="defaults.leaveMessage"
class="input"
name="leaveMessage"
placeholder="The Lounge - https://thelounge.chat"
/>
</div>
<template v-if="defaults.uuid && !$store.state.serverConfiguration.public"> <template v-if="defaults.uuid && !$store.state.serverConfiguration.public">
<div class="connect-row"> <div class="connect-row">
<label for="connect:commands"> <label for="connect:commands">
@ -270,9 +312,7 @@ the server tab on new connection"
</div> </div>
</template> </template>
<div v-else-if="defaults.sasl === 'external'" class="connect-sasl-external"> <div v-else-if="defaults.sasl === 'external'" class="connect-sasl-external">
<p> <p>The Lounge automatically generates and manages the client certificate.</p>
The Lounge automatically generates and manages the client certificate.
</p>
<p> <p>
On the IRC server, you will need to tell the services to attach the On the IRC server, you will need to tell the services to attach the
certificate fingerprint (certfp) to your account, for example: certificate fingerprint (certfp) to your account, for example:

View file

@ -46,9 +46,7 @@
/> />
</div> </div>
</div> </div>
<div v-else class="no-results"> <div v-else class="no-results">No results found.</div>
No results found.
</div>
</div> </div>
<Draggable <Draggable
v-else v-else
@ -84,13 +82,13 @@
$store.state.activeChannel && $store.state.activeChannel &&
network.channels[0] === $store.state.activeChannel.channel network.channels[0] === $store.state.activeChannel.channel
" "
@toggleJoinChannel="network.isJoinChannelShown = !network.isJoinChannelShown" @toggle-join-channel="network.isJoinChannelShown = !network.isJoinChannelShown"
/> />
<JoinChannel <JoinChannel
v-if="network.isJoinChannelShown" v-if="network.isJoinChannelShown"
:network="network" :network="network"
:channel="network.channels[0]" :channel="network.channels[0]"
@toggleJoinChannel="network.isJoinChannelShown = !network.isJoinChannelShown" @toggle-join-channel="network.isJoinChannelShown = !network.isJoinChannelShown"
/> />
<Draggable <Draggable
@ -106,17 +104,18 @@
@start="onDragStart" @start="onDragStart"
@end="onDragEnd" @end="onDragEnd"
> >
<Channel <template v-for="(channel, index) in network.channels">
v-for="(channel, index) in network.channels" <Channel
v-if="index > 0" v-if="index > 0"
:key="channel.id" :key="channel.id"
:channel="channel" :channel="channel"
:network="network" :network="network"
:active=" :active="
$store.state.activeChannel && $store.state.activeChannel &&
channel === $store.state.activeChannel.channel channel === $store.state.activeChannel.channel
" "
/> />
</template>
</Draggable> </Draggable>
</div> </div>
</Draggable> </Draggable>

View file

@ -39,7 +39,7 @@
:class="['add-channel', {opened: isJoinChannelShown}]" :class="['add-channel', {opened: isJoinChannelShown}]"
:aria-controls="'join-channel-' + channel.id" :aria-controls="'join-channel-' + channel.id"
:aria-label="joinChannelLabel" :aria-label="joinChannelLabel"
@click.stop="$emit('toggleJoinChannel')" @click.stop="$emit('toggle-join-channel')"
/> />
</span> </span>
</ChannelWrapper> </ChannelWrapper>

View file

@ -6,11 +6,12 @@
v-on="onHover ? {mouseenter: hover} : {}" v-on="onHover ? {mouseenter: hover} : {}"
@click.prevent="openContextMenu" @click.prevent="openContextMenu"
@contextmenu.prevent="openContextMenu" @contextmenu.prevent="openContextMenu"
><slot>{{ user.mode }}{{ user.nick }}</slot></span ><slot>{{ mode }}{{ user.nick }}</slot></span
> >
</template> </template>
<script> <script>
import eventbus from "../js/eventbus";
import colorClass from "../js/helpers/colorClass"; import colorClass from "../js/helpers/colorClass";
export default { export default {
@ -23,6 +24,14 @@ export default {
network: Object, network: Object,
}, },
computed: { computed: {
mode() {
// Message objects have a singular mode, but user objects have modes array
if (this.user.modes) {
return this.user.modes[0];
}
return this.user.mode;
},
nickColor() { nickColor() {
return colorClass(this.user.nick); return colorClass(this.user.nick);
}, },
@ -32,7 +41,7 @@ export default {
return this.onHover(this.user); return this.onHover(this.user);
}, },
openContextMenu(event) { openContextMenu(event) {
this.$root.$emit("contextmenu:user", { eventbus.emit("contextmenu:user", {
event: event, event: event,
user: this.user, user: this.user,
network: this.network, network: this.network,

View file

@ -1,13 +1,9 @@
<template> <template>
<div id="version-checker" :class="[$store.state.versionStatus]"> <div id="version-checker" :class="[$store.state.versionStatus]">
<p v-if="$store.state.versionStatus === 'loading'"> <p v-if="$store.state.versionStatus === 'loading'">Checking for updates</p>
Checking for updates
</p>
<p v-if="$store.state.versionStatus === 'new-version'"> <p v-if="$store.state.versionStatus === 'new-version'">
The Lounge <b>{{ $store.state.versionData.latest.version }}</b> The Lounge <b>{{ $store.state.versionData.latest.version }}</b>
<template v-if="$store.state.versionData.latest.prerelease"> <template v-if="$store.state.versionData.latest.prerelease"> (pre-release) </template>
(pre-release)
</template>
is now available. is now available.
<br /> <br />
@ -20,9 +16,7 @@
<code>thelounge upgrade</code> on the server to upgrade packages. <code>thelounge upgrade</code> on the server to upgrade packages.
</p> </p>
<template v-if="$store.state.versionStatus === 'up-to-date'"> <template v-if="$store.state.versionStatus === 'up-to-date'">
<p> <p>The Lounge is up to date!</p>
The Lounge is up to date!
</p>
<button <button
v-if="$store.state.versionDataExpired" v-if="$store.state.versionDataExpired"
@ -34,9 +28,7 @@
</button> </button>
</template> </template>
<template v-if="$store.state.versionStatus === 'error'"> <template v-if="$store.state.versionStatus === 'error'">
<p> <p>Information about latest release could not be retrieved.</p>
Information about latest release could not be retrieved.
</p>
<button id="check-now" class="btn btn-small" @click="checkNow">Try again</button> <button id="check-now" class="btn btn-small" @click="checkNow">Try again</button>
</template> </template>

View file

@ -318,9 +318,7 @@
<kbd></kbd> keys to highlight an item, and insert it by pressing <kbd>Tab</kbd> or <kbd></kbd> keys to highlight an item, and insert it by pressing <kbd>Tab</kbd> or
<kbd>Enter</kbd> (or by clicking the desired item). <kbd>Enter</kbd> (or by clicking the desired item).
</p> </p>
<p> <p>Autocompletion can be disabled in settings.</p>
Autocompletion can be disabled in settings.
</p>
<div class="help-item"> <div class="help-item">
<div class="subject"> <div class="subject">
@ -474,9 +472,7 @@
<code>/disconnect [message]</code> <code>/disconnect [message]</code>
</div> </div>
<div class="description"> <div class="description">
<p> <p>Disconnect from the current network with an optionally-provided message.</p>
Disconnect from the current network with an optionally-provided message.
</p>
</div> </div>
</div> </div>
@ -610,9 +606,7 @@
<code>/op nick [...nick]</code> <code>/op nick [...nick]</code>
</div> </div>
<div class="description"> <div class="description">
<p> <p>Give op (<code>+o</code>) to one or several users in the current channel.</p>
Give op (<code>+o</code>) to one or several users in the current channel.
</p>
</div> </div>
</div> </div>
@ -656,9 +650,7 @@
<code>/quit [message]</code> <code>/quit [message]</code>
</div> </div>
<div class="description"> <div class="description">
<p> <p>Disconnect from the current network with an optional message.</p>
Disconnect from the current network with an optional message.
</p>
</div> </div>
</div> </div>
@ -733,9 +725,7 @@
<code>/whois nick</code> <code>/whois nick</code>
</div> </div>
<div class="description"> <div class="description">
<p> <p>Retrieve information about the given user on the current network.</p>
Retrieve information about the given user on the current network.
</p>
</div> </div>
</div> </div>
</div> </div>

View file

@ -172,8 +172,14 @@
</div> </div>
<div v-if="$store.state.settings.advanced"> <div v-if="$store.state.settings.advanced">
<label class="opt"> <label class="opt">
<label for="nickPostfix" class="sr-only"> <label for="nickPostfix" class="opt">
Nick autocomplete postfix (for example a comma) Nick autocomplete postfix
<span
class="tooltipped tooltipped-n tooltipped-no-delay"
aria-label="Nick autocomplete postfix (for example a comma)"
>
<button class="extra-help" />
</span>
</label> </label>
<input <input
id="nickPostfix" id="nickPostfix"
@ -271,9 +277,7 @@ This may break orientation if your browser does not support that."
<template v-else-if="$store.state.pushNotificationState === 'loading'"> <template v-else-if="$store.state.pushNotificationState === 'loading'">
Loading Loading
</template> </template>
<template v-else> <template v-else> Subscribe to push notifications </template>
Subscribe to push notifications
</template>
</button> </button>
<div v-if="$store.state.pushNotificationState === 'nohttps'" class="error"> <div v-if="$store.state.pushNotificationState === 'nohttps'" class="error">
<strong>Warning</strong>: Push notifications are only supported over HTTPS <strong>Warning</strong>: Push notifications are only supported over HTTPS
@ -351,8 +355,15 @@ This may break orientation if your browser does not support that."
<div v-if="!$store.state.serverConfiguration.public && $store.state.settings.advanced"> <div v-if="!$store.state.serverConfiguration.public && $store.state.settings.advanced">
<label class="opt"> <label class="opt">
<label for="highlights" class="sr-only"> <label for="highlights" class="opt">
Custom highlights (comma-separated keywords) Custom highlights
<span
class="tooltipped tooltipped-n tooltipped-no-delay"
aria-label="If a message contains any of these comma-separated
expressions, it will trigger a highlight."
>
<button class="extra-help" />
</span>
</label> </label>
<input <input
id="highlights" id="highlights"
@ -360,7 +371,31 @@ This may break orientation if your browser does not support that."
type="text" type="text"
name="highlights" name="highlights"
class="input" class="input"
placeholder="Custom highlights (comma-separated keywords)" placeholder="Comma-separated, e.g.: word, some more words, anotherword"
/>
</label>
</div>
<div v-if="!$store.state.serverConfiguration.public && $store.state.settings.advanced">
<label class="opt">
<label for="highlightExceptions" class="opt">
Highlight exceptions
<span
class="tooltipped tooltipped-n tooltipped-no-delay"
aria-label="If a message contains any of these comma-separated
expressions, it will not trigger a highlight even if it contains
your nickname or expressions defined in custom highlights."
>
<button class="extra-help" />
</span>
</label>
<input
id="highlightExceptions"
:value="$store.state.settings.highlightExceptions"
type="text"
name="highlightExceptions"
class="input"
placeholder="Comma-separated, e.g.: word, some more words, anotherword"
/> />
</label> </label>
</div> </div>
@ -376,9 +411,7 @@ This may break orientation if your browser does not support that."
> >
<h2 id="label-change-password">Change password</h2> <h2 id="label-change-password">Change password</h2>
<div class="password-container"> <div class="password-container">
<label for="old_password_input" class="sr-only"> <label for="old_password_input" class="sr-only"> Enter current password </label>
Enter current password
</label>
<RevealPassword v-slot:default="slotProps"> <RevealPassword v-slot:default="slotProps">
<input <input
id="old_password_input" id="old_password_input"
@ -404,9 +437,7 @@ This may break orientation if your browser does not support that."
</RevealPassword> </RevealPassword>
</div> </div>
<div class="password-container"> <div class="password-container">
<label for="verify_password_input" class="sr-only"> <label for="verify_password_input" class="sr-only"> Repeat new password </label>
Repeat new password
</label>
<RevealPassword v-slot:default="slotProps"> <RevealPassword v-slot:default="slotProps">
<input <input
id="verify_password_input" id="verify_password_input"

View file

@ -144,7 +144,7 @@ button {
code, code,
pre, pre,
#chat .msg[data-type="motd"] .text, #chat .msg[data-type="monospace_block"] .text,
.irc-monospace, .irc-monospace,
textarea#user-specified-css-input { textarea#user-specified-css-input {
font-family: Consolas, Menlo, Monaco, "Lucida Console", "DejaVu Sans Mono", "Courier New", monospace; font-family: Consolas, Menlo, Monaco, "Lucida Console", "DejaVu Sans Mono", "Courier New", monospace;
@ -304,7 +304,9 @@ p {
#chat .msg[data-type="topic"] .from::before, #chat .msg[data-type="topic"] .from::before,
#chat .msg[data-type="mode_channel"] .from::before, #chat .msg[data-type="mode_channel"] .from::before,
#chat .msg[data-type="mode"] .from::before, #chat .msg[data-type="mode"] .from::before,
#chat .msg[data-type="motd"] .from::before, #chat .msg[data-command="motd"] .from::before,
#chat .msg[data-command="help"] .from::before,
#chat .msg[data-command="info"] .from::before,
#chat .msg[data-type="ctcp"] .from::before, #chat .msg[data-type="ctcp"] .from::before,
#chat .msg[data-type="ctcp_request"] .from::before, #chat .msg[data-type="ctcp_request"] .from::before,
#chat .msg[data-type="whois"] .from::before, #chat .msg[data-type="whois"] .from::before,
@ -312,6 +314,7 @@ p {
#chat .msg[data-type="action"] .from::before, #chat .msg[data-type="action"] .from::before,
#chat .msg[data-type="plugin"] .from::before, #chat .msg[data-type="plugin"] .from::before,
#chat .msg[data-type="raw"] .from::before, #chat .msg[data-type="raw"] .from::before,
#chat .msg-statusmsg span::before,
#chat .msg-shown-in-active span::before, #chat .msg-shown-in-active span::before,
#chat .toggle-button::after, #chat .toggle-button::after,
#chat .toggle-content .more-caret::before, #chat .toggle-content .more-caret::before,
@ -349,9 +352,10 @@ p {
.context-menu-disconnect::before { content: "\f127"; /* https://fontawesome.com/icons/unlink?style=solid */ } .context-menu-disconnect::before { content: "\f127"; /* https://fontawesome.com/icons/unlink?style=solid */ }
.context-menu-connect::before { content: "\f0c1"; /* https://fontawesome.com/icons/link?style=solid */ } .context-menu-connect::before { content: "\f0c1"; /* https://fontawesome.com/icons/link?style=solid */ }
.context-menu-action-whois::before { content: "\f05a"; /* http://fontawesome.io/icon/info-circle/ */ } .context-menu-action-whois::before { content: "\f05a"; /* http://fontawesome.io/icon/info-circle/ */ }
.context-menu-action-ignore::before { content: "\f506"; /* https://fontawesome.com/icons/user-slash?style=solid */ }
.context-menu-action-kick::before { content: "\f05e"; /* http://fontawesome.io/icon/ban/ */ } .context-menu-action-kick::before { content: "\f05e"; /* http://fontawesome.io/icon/ban/ */ }
.context-menu-action-op::before { content: "\f1fa"; /* http://fontawesome.io/icon/at/ */ } .context-menu-action-set-mode::before { content: "\f067"; /* http://fontawesome.io/icon/plus/ */ }
.context-menu-action-voice::before { content: "\f067"; /* http://fontawesome.io/icon/plus/ */ } .context-menu-action-revoke-mode::before { content: "\f068"; /* http://fontawesome.io/icon/minus/ */ }
.context-menu-network::before { content: "\f233"; /* https://fontawesome.com/icons/server?style=solid */ } .context-menu-network::before { content: "\f233"; /* https://fontawesome.com/icons/server?style=solid */ }
.context-menu-edit::before { content: "\f303"; /* https://fontawesome.com/icons/pencil-alt?style=solid */ } .context-menu-edit::before { content: "\f303"; /* https://fontawesome.com/icons/pencil-alt?style=solid */ }
.context-menu-clear-history::before { content: "\f1f8"; /* https://fontawesome.com/icons/trash?style=solid */ } .context-menu-clear-history::before { content: "\f1f8"; /* https://fontawesome.com/icons/trash?style=solid */ }
@ -428,11 +432,21 @@ p {
color: #2ecc40; color: #2ecc40;
} }
#chat .msg[data-type="motd"] .from::before { #chat .msg[data-command="motd"] .from::before {
content: "\f02e"; /* https://fontawesome.com/icons/bookmark?style=solid */ content: "\f02e"; /* https://fontawesome.com/icons/bookmark?style=solid */
color: var(--body-color-muted); color: var(--body-color-muted);
} }
#chat .msg[data-command="help"] .from::before {
content: "\f059"; /* https://fontawesome.com/icons/question-circle?style=solid */
color: var(--body-color-muted);
}
#chat .msg[data-command="info"] .from::before {
content: "\f05a"; /* https://fontawesome.com/icons/info-circle?style=solid */
color: var(--body-color-muted);
}
#chat .msg[data-type="ctcp"] .from::before, #chat .msg[data-type="ctcp"] .from::before,
#chat .msg[data-type="ctcp_request"] .from::before { #chat .msg[data-type="ctcp_request"] .from::before {
content: "\f15c"; /* https://fontawesome.com/icons/file-alt?style=solid */ content: "\f15c"; /* https://fontawesome.com/icons/file-alt?style=solid */
@ -479,16 +493,25 @@ p {
padding: 1px; padding: 1px;
} }
#chat .msg-statusmsg,
#chat .msg-shown-in-active { #chat .msg-shown-in-active {
cursor: help; cursor: help;
margin-right: 5px; margin-right: 5px;
} }
#chat .msg-statusmsg span::before,
#chat .msg-shown-in-active span::before { #chat .msg-shown-in-active span::before {
font-size: 10px; font-size: 10px;
content: "\f06e"; /* https://fontawesome.com/icons/eye?style=solid */ content: "\f06e"; /* https://fontawesome.com/icons/eye?style=solid */
} }
#chat .msg-statusmsg {
border-radius: 2px;
padding: 2px 4px;
background-color: #ff9e18;
color: #222;
}
#chat .toggle-button { #chat .toggle-button {
display: inline-block; display: inline-block;
transition: opacity 0.2s, transform 0.2s; transition: opacity 0.2s, transform 0.2s;
@ -1448,11 +1471,11 @@ textarea.input {
width: 50px; width: 50px;
} }
#chat.hide-motd .msg[data-type="motd"] { #chat.hide-motd .msg[data-command="motd"] {
display: none !important; display: none !important;
} }
#chat .msg[data-type="motd"] .text { #chat .msg[data-type="monospace_block"] .text {
background: #f6f6f6; background: #f6f6f6;
display: inline-block; display: inline-block;
border-radius: 4px; border-radius: 4px;
@ -2020,6 +2043,14 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
padding-bottom: 7px; padding-bottom: 7px;
} }
.window#chat-container {
/*
Chat has its own scrollbar, so remove the one on parent
This caused a performance issue in Chrome
*/
overflow: hidden;
}
#version-checker { #version-checker {
display: flex; display: flex;
align-items: center; align-items: center;
@ -2812,7 +2843,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
.header .topic, .header .topic,
#chat .msg[data-type="action"] .content, #chat .msg[data-type="action"] .content,
#chat .msg[data-type="message"] .content, #chat .msg[data-type="message"] .content,
#chat .msg[data-type="motd"] .content, #chat .msg[data-type="monospace_block"] .content,
#chat .msg[data-type="notice"] .content, #chat .msg[data-type="notice"] .content,
#chat .ctcp-message, #chat .ctcp-message,
#chat .part-reason, #chat .part-reason,

View file

@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">

51
client/js/eventbus.js Normal file
View file

@ -0,0 +1,51 @@
const events = new Map();
class EventBus {
/**
* Register an event handler for the given type.
*
* @param {String} type Type of event to listen for.
* @param {Function} handler Function to call in response to given event.
*/
on(type, handler) {
if (events.has(type)) {
events.get(type).push(handler);
} else {
events.set(type, [handler]);
}
}
/**
* Remove an event handler for the given type.
*
* @param {String} type Type of event to unregister `handler` from.
* @param {Function} handler Handler function to remove.
*/
off(type, handler) {
if (events.has(type)) {
events.set(
type,
events.get(type).filter((item) => item !== handler)
);
}
}
/**
* Invoke all handlers for the given type.
*
* @param {String} type The event type to invoke.
* @param {Any} [evt] Any value (object is recommended and powerful), passed to each handler.
*/
emit(type, ...evt) {
if (events.has(type)) {
events
.get(type)
.slice()
.map((handler) => {
handler(...evt);
});
}
}
}
export default new EventBus();

View file

@ -1,6 +1,7 @@
"use strict"; "use strict";
import socket from "../socket"; import socket from "../socket";
import eventbus from "../eventbus";
export function generateChannelContextMenu($root, channel, network) { export function generateChannelContextMenu($root, channel, network) {
const typeMap = { const typeMap = {
@ -115,18 +116,31 @@ export function generateChannelContextMenu($root, channel, network) {
// Add menu items for queries // Add menu items for queries
if (channel.type === "query") { if (channel.type === "query") {
items.push({ items.push(
label: "User information", {
type: "item", label: "User information",
class: "action-whois", type: "item",
action() { class: "action-whois",
$root.switchToChannel(channel); action() {
socket.emit("input", { $root.switchToChannel(channel);
target: channel.id, socket.emit("input", {
text: "/whois " + channel.name, target: channel.id,
}); text: "/whois " + channel.name,
});
},
}, },
}); {
label: "Ignore user",
type: "item",
class: "action-ignore",
action() {
socket.emit("input", {
target: channel.id,
text: "/ignore " + channel.name,
});
},
}
);
} }
if (channel.type === "channel" || channel.type === "query") { if (channel.type === "channel" || channel.type === "query") {
@ -135,7 +149,7 @@ export function generateChannelContextMenu($root, channel, network) {
type: "item", type: "item",
class: "clear-history", class: "clear-history",
action() { action() {
$root.$emit( eventbus.emit(
"confirm-dialog", "confirm-dialog",
{ {
title: "Clear history", title: "Clear history",
@ -203,6 +217,17 @@ export function generateUserContextMenu($root, channel, network, user) {
class: "action-whois", class: "action-whois",
action: whois, action: whois,
}, },
{
label: "Ignore user",
type: "item",
class: "action-ignore",
action() {
socket.emit("input", {
target: channel.id,
text: "/ignore " + user.nick,
});
},
},
{ {
label: "Direct messages", label: "Direct messages",
type: "item", type: "item",
@ -222,66 +247,93 @@ export function generateUserContextMenu($root, channel, network, user) {
}, },
]; ];
if (currentChannelUser.mode === "@") { // Bail because we're in a query or we don't have a special mode.
items.push({ if (!currentChannelUser.modes || currentChannelUser.modes.length < 1) {
label: "Kick", return items;
type: "item", }
class: "action-kick",
action() {
socket.emit("input", {
target: channel.id,
text: "/kick " + user.nick,
});
},
});
if (user.mode === "@") { // Names of the modes we are able to change
const modes = {
"~": ["owner", "q"],
"&": ["admin", "a"],
"@": ["operator", "o"],
"%": ["half-op", "h"],
"+": ["voice", "v"],
};
// Labels for the mode changes. For example .rev(['admin', 'a']) => 'Revoke admin (-a)'
const modeTextTemplate = {
revoke: (m) => `Revoke ${m[0]} (-${m[1]})`,
give: (m) => `Give ${m[0]} (+${m[1]})`,
};
const networkModes = network.serverOptions.PREFIX;
/**
* Determine whether the prefix of mode p1 has access to perform actions on p2.
*
* EXAMPLE:
* compare('@', '@') => true
* compare('&', '@') => true
* compare('+', '~') => false
* @param {string} p1 The mode performing an action
* @param {string} p2 The target mode
*
* @return {boolean} whether p1 can perform an action on p2
*/
function compare(p1, p2) {
// The modes ~ and @ can perform actions on their own mode. The others on modes below.
return "~@".indexOf(p1) > -1
? networkModes.indexOf(p1) <= networkModes.indexOf(p2)
: networkModes.indexOf(p1) < networkModes.indexOf(p2);
}
networkModes.forEach((prefix) => {
if (!compare(currentChannelUser.modes[0], prefix)) {
// Our highest mode is below the current mode. Bail.
return;
}
if (!user.modes.includes(prefix)) {
// The target doesn't already have this mode, therefore we can set it.
items.push({ items.push({
label: "Revoke operator (-o)", label: modeTextTemplate.give(modes[prefix]),
type: "item", type: "item",
class: "action-op", class: "action-set-mode",
action() { action() {
socket.emit("input", { socket.emit("input", {
target: channel.id, target: channel.id,
text: "/deop " + user.nick, text: "/mode +" + modes[prefix][1] + " " + user.nick,
}); });
}, },
}); });
} else { } else {
items.push({ items.push({
label: "Give operator (+o)", label: modeTextTemplate.revoke(modes[prefix]),
type: "item", type: "item",
class: "action-op", class: "action-revoke-mode",
action() { action() {
socket.emit("input", { socket.emit("input", {
target: channel.id, target: channel.id,
text: "/op " + user.nick, text: "/mode -" + modes[prefix][1] + " " + user.nick,
}); });
}, },
}); });
} }
});
if (user.mode === "+") { // Determine if we are half-op or op depending on the network modes so we can kick.
if (!compare(networkModes.indexOf("%") > -1 ? "%" : "@", currentChannelUser.modes[0])) {
if (user.modes.length === 0 || compare(currentChannelUser.modes[0], user.modes[0])) {
// Check if the target user has no mode or a mode lower than ours.
items.push({ items.push({
label: "Revoke voice (-v)", label: "Kick",
type: "item", type: "item",
class: "action-voice", class: "action-kick",
action() { action() {
socket.emit("input", { socket.emit("input", {
target: channel.id, target: channel.id,
text: "/devoice " + user.nick, text: "/kick " + user.nick,
});
},
});
} else {
items.push({
label: "Give voice (+v)",
type: "item",
class: "action-voice",
action() {
socket.emit("input", {
target: channel.id,
text: "/voice " + user.nick,
}); });
}, },
}); });

View file

@ -6,11 +6,13 @@ LinkifyIt.prototype.normalize = function normalize(match) {
if (!match.schema) { if (!match.schema) {
match.schema = "http:"; match.schema = "http:";
match.url = "http://" + match.url; match.url = "http://" + match.url;
match.noschema = true;
} }
if (match.schema === "//") { if (match.schema === "//") {
match.schema = "http:"; match.schema = "http:";
match.url = "http:" + match.url; match.url = "http:" + match.url;
match.noschema = true;
} }
if (match.schema === "mailto:" && !/^mailto:/i.test(match.url)) { if (match.schema === "mailto:" && !/^mailto:/i.test(match.url)) {
@ -34,6 +36,8 @@ const commonSchemes = [
"ts3server", "ts3server",
"svn+ssh", "svn+ssh",
"ssh", "ssh",
"gopher",
"gemini",
]; ];
for (const schema of commonSchemes) { for (const schema of commonSchemes) {
@ -47,11 +51,28 @@ function findLinks(text) {
return []; return [];
} }
return matches.map((url) => ({ return matches.map(returnUrl);
}
function findLinksWithSchema(text) {
const matches = linkify.match(text);
if (!matches) {
return [];
}
return matches.filter((url) => !url.noschema).map(returnUrl);
}
function returnUrl(url) {
return {
start: url.index, start: url.index,
end: url.lastIndex, end: url.lastIndex,
link: url.url, link: url.url,
})); };
} }
module.exports = findLinks; module.exports = {
findLinks,
findLinksWithSchema,
};

View file

@ -2,7 +2,7 @@
import parseStyle from "./ircmessageparser/parseStyle"; import parseStyle from "./ircmessageparser/parseStyle";
import findChannels from "./ircmessageparser/findChannels"; import findChannels from "./ircmessageparser/findChannels";
import findLinks from "./ircmessageparser/findLinks"; import {findLinks} from "./ircmessageparser/findLinks";
import findEmoji from "./ircmessageparser/findEmoji"; import findEmoji from "./ircmessageparser/findEmoji";
import findNames from "./ircmessageparser/findNames"; import findNames from "./ircmessageparser/findNames";
import merge from "./ircmessageparser/merge"; import merge from "./ircmessageparser/merge";

View file

@ -613,7 +613,7 @@
"dragon_face": "🐲", "dragon_face": "🐲",
"dragon": "🐉", "dragon": "🐉",
"sauropod": "🦕", "sauropod": "🦕",
"t-rex": "🦖", "t_rex": "🦖",
"whale": "🐳", "whale": "🐳",
"whale2": "🐋", "whale2": "🐋",
"dolphin": "🐬", "dolphin": "🐬",
@ -1082,7 +1082,7 @@
"game_die": "🎲", "game_die": "🎲",
"jigsaw": "🧩", "jigsaw": "🧩",
"teddy_bear": "🧸", "teddy_bear": "🧸",
"pi_ata": "🪅", "pinata": "🪅",
"nesting_dolls": "🪆", "nesting_dolls": "🪆",
"spades": "♠️", "spades": "♠️",
"hearts": "♥️", "hearts": "♥️",
@ -1240,7 +1240,7 @@
"chart": "💹", "chart": "💹",
"email": "✉️", "email": "✉️",
"envelope": "✉️", "envelope": "✉️",
"e-mail": "📧", "e_mail": "📧",
"incoming_envelope": "📨", "incoming_envelope": "📨",
"envelope_with_arrow": "📩", "envelope_with_arrow": "📩",
"outbox_tray": "📤", "outbox_tray": "📤",
@ -1376,7 +1376,7 @@
"no_bicycles": "🚳", "no_bicycles": "🚳",
"no_smoking": "🚭", "no_smoking": "🚭",
"do_not_litter": "🚯", "do_not_litter": "🚯",
"non-potable_water": "🚱", "non_potable_water": "🚱",
"no_pedestrians": "🚷", "no_pedestrians": "🚷",
"no_mobile_phones": "📵", "no_mobile_phones": "📵",
"underage": "🔞", "underage": "🔞",

View file

@ -33,12 +33,63 @@ const router = new VueRouter({
next(); next();
}, },
}, },
{
name: "Connect",
path: "/connect",
component: Connect,
props: (route) => ({queryParams: route.query}),
},
{
name: "Settings",
path: "/settings",
component: Settings,
},
{
name: "Help",
path: "/help",
component: Help,
},
{
name: "Changelog",
path: "/changelog",
component: Changelog,
},
{
name: "NetworkEdit",
path: "/edit-network/:uuid",
component: NetworkEdit,
},
{
name: "RoutedChat",
path: "/chan-:id",
component: RoutedChat,
},
{
name: "SearchResults",
path: "/search/:uuid/:target/:term",
component: SearchResults,
},
], ],
}); });
router.beforeEach((to, from, next) => {
// If user is not yet signed in, wait for appLoaded state to change
// unless they are trying to open SignIn (which can be triggered in auth.js)
if (!store.state.appLoaded && to.name !== "SignIn") {
store.watch(
(state) => state.appLoaded,
() => next()
);
return;
}
next();
});
router.beforeEach((to, from, next) => { router.beforeEach((to, from, next) => {
// Disallow navigating to non-existing routes // Disallow navigating to non-existing routes
if (store.state.appLoaded && !to.matched.length) { if (!to.matched.length) {
next(false); next(false);
return; return;
} }
@ -49,6 +100,12 @@ router.beforeEach((to, from, next) => {
return; return;
} }
// Disallow navigating to invalid networks
if (to.name === "NetworkEdit" && !store.getters.findNetwork(to.params.uuid)) {
next(false);
return;
}
// Handle closing image viewer with the browser back button // Handle closing image viewer with the browser back button
if (!router.app.$refs.app) { if (!router.app.$refs.app) {
next(); next();
@ -92,47 +149,6 @@ router.afterEach((to) => {
} }
}); });
function initialize() {
router.addRoutes([
{
name: "Connect",
path: "/connect",
component: Connect,
props: (route) => ({queryParams: route.query}),
},
{
name: "Settings",
path: "/settings",
component: Settings,
},
{
name: "Help",
path: "/help",
component: Help,
},
{
name: "Changelog",
path: "/changelog",
component: Changelog,
},
{
name: "NetworkEdit",
path: "/edit-network/:uuid",
component: NetworkEdit,
},
{
name: "RoutedChat",
path: "/chan-:id",
component: RoutedChat,
},
{
name: "SearchResults",
path: "/search/:uuid/:target/:term",
component: SearchResults,
},
]);
}
function navigate(routeName, params = {}) { function navigate(routeName, params = {}) {
if (router.currentRoute.name) { if (router.currentRoute.name) {
router.push({name: routeName, params}).catch(() => {}); router.push({name: routeName, params}).catch(() => {});
@ -162,4 +178,4 @@ if ("serviceWorker" in navigator) {
}); });
} }
export {initialize, router, navigate, switchToChannel}; export {router, navigate, switchToChannel};

View file

@ -31,6 +31,7 @@ export const config = normalizeConfig({
}, },
desktopNotifications: { desktopNotifications: {
default: false, default: false,
sync: "never",
apply(store, value) { apply(store, value) {
store.commit("refreshDesktopNotificationState", null, {root: true}); store.commit("refreshDesktopNotificationState", null, {root: true});
@ -45,6 +46,10 @@ export const config = normalizeConfig({
default: "", default: "",
sync: "always", sync: "always",
}, },
highlightExceptions: {
default: "",
sync: "always",
},
awayMessage: { awayMessage: {
default: "", default: "",
sync: "always", sync: "always",
@ -57,6 +62,7 @@ export const config = normalizeConfig({
}, },
notification: { notification: {
default: true, default: true,
sync: "never",
}, },
notifyAllMessages: { notifyAllMessages: {
default: false, default: false,

View file

@ -5,7 +5,7 @@ socket.on("disconnect", handleDisconnect);
socket.on("connect_error", handleDisconnect); socket.on("connect_error", handleDisconnect);
socket.on("error", handleDisconnect); socket.on("error", handleDisconnect);
socket.on("reconnecting", function (attempt) { socket.io.on("reconnect_attempt", function (attempt) {
store.commit("currentUserVisibleError", `Reconnecting… (attempt ${attempt})`); store.commit("currentUserVisibleError", `Reconnecting… (attempt ${attempt})`);
updateLoadingMessage(); updateLoadingMessage();
}); });

View file

@ -1,8 +1,9 @@
"use strict"; "use strict";
import Vue from "vue";
import socket from "../socket"; import socket from "../socket";
import storage from "../localStorage"; import storage from "../localStorage";
import {router, switchToChannel, navigate, initialize as routerInitialize} from "../router"; import {router, switchToChannel, navigate} from "../router";
import store from "../store"; import store from "../store";
import parseIrcUri from "../helpers/parseIrcUri"; import parseIrcUri from "../helpers/parseIrcUri";
@ -16,10 +17,6 @@ socket.on("init", function (data) {
} }
if (!store.state.appLoaded) { if (!store.state.appLoaded) {
// Routes are initialized after networks data is merged
// so the route guard for channels works correctly on page load
routerInitialize();
store.commit("appLoaded"); store.commit("appLoaded");
socket.emit("setting:get"); socket.emit("setting:get");
@ -28,24 +25,27 @@ socket.on("init", function (data) {
window.g_TheLoungeRemoveLoading(); window.g_TheLoungeRemoveLoading();
} }
// TODO: Review this code and make it better Vue.nextTick(() => {
if (!router.currentRoute.name || router.currentRoute.name === "SignIn") { // If we handled query parameters like irc:// links or just general
const channel = store.getters.findChannel(data.active); // connect parameters in public mode, then nothing to do here
if (!handleQueryParams()) {
// If we are on an unknown route or still on SignIn component
// then we can open last known channel on server, or Connect window if none
if (!router.currentRoute.name || router.currentRoute.name === "SignIn") {
const channel = store.getters.findChannel(data.active);
if (channel) { if (channel) {
switchToChannel(channel.channel); switchToChannel(channel.channel);
} else if (store.state.networks.length > 0) { } else if (store.state.networks.length > 0) {
// Server is telling us to open a channel that does not exist // Server is telling us to open a channel that does not exist
// For example, it can be unset if you first open the page after server start // For example, it can be unset if you first open the page after server start
switchToChannel(store.state.networks[0].channels[0]); switchToChannel(store.state.networks[0].channels[0]);
} else { } else {
navigate("Connect"); navigate("Connect");
}
}
} }
} });
if ("URLSearchParams" in window) {
handleQueryParams();
}
} }
}); });
@ -154,6 +154,10 @@ function mergeChannelData(oldChannels, newChannels) {
} }
function handleQueryParams() { function handleQueryParams() {
if (!("URLSearchParams" in window)) {
return false;
}
const params = new URLSearchParams(document.location.search); const params = new URLSearchParams(document.location.search);
const cleanParams = () => { const cleanParams = () => {
@ -169,11 +173,17 @@ function handleQueryParams() {
cleanParams(); cleanParams();
router.push({name: "Connect", query: queryParams}); router.push({name: "Connect", query: queryParams});
return true;
} else if (document.body.classList.contains("public") && document.location.search) { } else if (document.body.classList.contains("public") && document.location.search) {
// Set default connection settings from url params // Set default connection settings from url params
const queryParams = Object.fromEntries(params.entries()); const queryParams = Object.fromEntries(params.entries());
cleanParams(); cleanParams();
router.push({name: "Connect", query: queryParams}); router.push({name: "Connect", query: queryParams});
return true;
} }
return false;
} }

View file

@ -64,3 +64,8 @@ socket.on("network:info", function (data) {
Vue.set(network, key, data[key]); Vue.set(network, key, data[key]);
} }
}); });
socket.on("network:name", function (data) {
const network = store.getters.findNetwork(data.uuid);
network.name = network.channels[0].name = data.name;
});

View file

@ -82,7 +82,7 @@ function loadFromLocalStorage() {
} }
// Older The Lounge versions converted highlights to an array, turn it back into a string // Older The Lounge versions converted highlights to an array, turn it back into a string
if (typeof storedSettings.highlights === "object") { if (storedSettings.highlights !== null && typeof storedSettings.highlights === "object") {
storedSettings.highlights = storedSettings.highlights.join(", "); storedSettings.highlights = storedSettings.highlights.join(", ");
} }

View file

@ -9,6 +9,7 @@ class Uploader {
init() { init() {
this.xhr = null; this.xhr = null;
this.fileQueue = []; this.fileQueue = [];
this.tokenKeepAlive = null;
document.addEventListener("dragenter", (e) => this.dragEnter(e)); document.addEventListener("dragenter", (e) => this.dragEnter(e));
document.addEventListener("dragover", (e) => this.dragOver(e)); document.addEventListener("dragover", (e) => this.dragOver(e));
@ -131,10 +132,17 @@ class Uploader {
uploadNextFileInQueue(token) { uploadNextFileInQueue(token) {
const file = this.fileQueue.shift(); const file = this.fileQueue.shift();
// Tell the server that we are still upload to this token
// so it does not become invalidated and fail the upload.
// This issue only happens if The Lounge is proxied through other software
// as it may buffer the upload before the upload request will be processed by The Lounge.
this.tokenKeepAlive = setInterval(() => socket.emit("upload:ping", token), 40 * 1000);
if ( if (
store.state.settings.uploadCanvas && store.state.settings.uploadCanvas &&
file.type.startsWith("image/") && file.type.startsWith("image/") &&
!file.type.includes("svg") !file.type.includes("svg") &&
file.type !== "image/gif"
) { ) {
this.renderImage(file, (newFile) => this.performUpload(token, newFile)); this.renderImage(file, (newFile) => this.performUpload(token, newFile));
} else { } else {
@ -219,6 +227,11 @@ class Uploader {
handleResponse(response) { handleResponse(response) {
this.setProgress(0); this.setProgress(0);
if (this.tokenKeepAlive) {
clearInterval(this.tokenKeepAlive);
this.tokenKeepAlive = null;
}
if (response.error) { if (response.error) {
store.commit("currentUserVisibleError", response.error); store.commit("currentUserVisibleError", response.error);
return; return;

View file

@ -9,6 +9,7 @@ import App from "../components/App.vue";
import storage from "./localStorage"; import storage from "./localStorage";
import {router, navigate} from "./router"; import {router, navigate} from "./router";
import socket from "./socket"; import socket from "./socket";
import eventbus from "./eventbus";
import "./socket-events"; import "./socket-events";
import "./webpush"; import "./webpush";
@ -18,7 +19,7 @@ const favicon = document.getElementById("favicon");
const faviconNormal = favicon.getAttribute("href"); const faviconNormal = favicon.getAttribute("href");
const faviconAlerted = favicon.dataset.other; const faviconAlerted = favicon.dataset.other;
const vueApp = new Vue({ new Vue({
el: "#viewport", el: "#viewport",
router, router,
mounted() { mounted() {
@ -30,7 +31,7 @@ const vueApp = new Vue({
}, },
closeChannel(channel) { closeChannel(channel) {
if (channel.type === "lobby") { if (channel.type === "lobby") {
this.$root.$emit( eventbus.emit(
"confirm-dialog", "confirm-dialog",
{ {
title: "Remove network", title: "Remove network",
@ -75,7 +76,7 @@ store.watch(
(sidebarOpen) => { (sidebarOpen) => {
if (window.innerWidth > constants.mobileViewportPixels) { if (window.innerWidth > constants.mobileViewportPixels) {
storage.set("thelounge.state.sidebar", sidebarOpen); storage.set("thelounge.state.sidebar", sidebarOpen);
vueApp.$emit("resize"); eventbus.emit("resize");
} }
} }
); );
@ -84,7 +85,7 @@ store.watch(
(state) => state.userlistOpen, (state) => state.userlistOpen,
(userlistOpen) => { (userlistOpen) => {
storage.set("thelounge.state.userlist", userlistOpen); storage.set("thelounge.state.userlist", userlistOpen);
vueApp.$emit("resize"); eventbus.emit("resize");
} }
); );
@ -100,6 +101,14 @@ store.watch(
(_, getters) => getters.highlightCount, (_, getters) => getters.highlightCount,
(highlightCount) => { (highlightCount) => {
favicon.setAttribute("href", highlightCount > 0 ? faviconAlerted : faviconNormal); favicon.setAttribute("href", highlightCount > 0 ? faviconAlerted : faviconNormal);
if (navigator.setAppBadge) {
if (highlightCount > 0) {
navigator.setAppBadge(highlightCount);
} else {
navigator.clearAppBadge();
}
}
} }
); );

View file

@ -73,11 +73,13 @@ function togglePushSubscription() {
.then((subscription) => { .then((subscription) => {
socket.emit("push:register", subscription.toJSON()); socket.emit("push:register", subscription.toJSON());
store.commit("pushNotificationState", "subscribed"); store.commit("pushNotificationState", "subscribed");
store.commit("refreshDesktopNotificationState");
}); });
}) })
) )
.catch((err) => { .catch((err) => {
store.commit("pushNotificationState", "unsupported"); store.commit("pushNotificationState", "unsupported");
store.commit("refreshDesktopNotificationState");
console.error(err); // eslint-disable-line no-console console.error(err); // eslint-disable-line no-console
}); });
} }

View file

@ -124,7 +124,7 @@ body {
color: #f92772; color: #f92772;
} }
#chat .msg[data-type="motd"] .text, #chat .msg[data-type="monospace_block"] .text,
code, code,
.irc-monospace { .irc-monospace {
background: #28333d; background: #28333d;

View file

@ -110,15 +110,27 @@ module.exports = {
// This value is set to `false` by default. // This value is set to `false` by default.
prefetch: false, prefetch: false,
// ### `disableMediaPreview`
//
// When set to `true`, The Lounge will not preview media (images, video and
// audio) hosted on third-party sites. This ensures the client does not
// make any requests to external sites. If `prefetchStorage` is enabled,
// images proxied via the The Lounge will be previewed.
//
// This has no effect if `prefetch` is set to `false`.
//
// This value is set to `false` by default.
disableMediaPreview: false,
// ### `prefetchStorage` // ### `prefetchStorage`
// When set to `true`, The Lounge will store and proxy prefetched images and // When set to `true`, The Lounge will store and proxy prefetched images and
// thumbnails on the filesystem rather than directly display the content at // thumbnails on the filesystem rather than directly display the content at
// the original URLs. // the original URLs.
// //
// This improves security and privacy by not exposing the client IP address, // This option primarily exists to resolve mixed content warnings by not
// always loading images from The Lounge and making all assets secure, which // loading images from http hosts. This option does not work for video
// resolves mixed content warnings. // or audio as The Lounge will only load these from https hosts.
// //
// If storage is enabled, The Lounge will fetch and store images and thumbnails // If storage is enabled, The Lounge will fetch and store images and thumbnails
// in the `${THELOUNGE_HOME}/storage` folder. // in the `${THELOUNGE_HOME}/storage` folder.
@ -138,6 +150,15 @@ module.exports = {
// This value is set to `2048` kilobytes by default. // This value is set to `2048` kilobytes by default.
prefetchMaxImageSize: 2048, prefetchMaxImageSize: 2048,
// ### prefetchMaxSearchSize
//
// This value sets the maximum request size made to find the Open Graph tags
// for link previews. For some sites like YouTube this can easily exceed 300
// kilobytes.
//
// This value is set to `50` kilobytes by default.
prefetchMaxSearchSize: 50,
// ### `fileUpload` // ### `fileUpload`
// //
// Allow uploading files to the server hosting The Lounge. // Allow uploading files to the server hosting The Lounge.
@ -206,6 +227,7 @@ module.exports = {
// numbers from 0 to 9. For example, `Guest%%%` may become `Guest123`. // numbers from 0 to 9. For example, `Guest%%%` may become `Guest123`.
// - `username`: User name. // - `username`: User name.
// - `realname`: Real name. // - `realname`: Real name.
// - `leaveMessage`: Network specific leave message (overrides global leaveMessage)
// - `join`: Comma-separated list of channels to auto-join once connected. // - `join`: Comma-separated list of channels to auto-join once connected.
// //
// This value is set to connect to the official channel of The Lounge on // This value is set to connect to the official channel of The Lounge on
@ -236,6 +258,7 @@ module.exports = {
username: "thelounge", username: "thelounge",
realname: "The Lounge User", realname: "The Lounge User",
join: "#thelounge", join: "#thelounge",
leaveMessage: "",
}, },
// ### `lockNetwork` // ### `lockNetwork`

View file

@ -1,7 +1,7 @@
{ {
"name": "thelounge", "name": "thelounge",
"description": "The self-hosted Web IRC client", "description": "The self-hosted Web IRC client",
"version": "4.1.0", "version": "4.3.0-pre.1",
"preferGlobal": true, "preferGlobal": true,
"bin": { "bin": {
"thelounge": "index.js" "thelounge": "index.js"
@ -42,82 +42,83 @@
"dependencies": { "dependencies": {
"bcryptjs": "2.4.3", "bcryptjs": "2.4.3",
"busboy": "0.3.1", "busboy": "0.3.1",
"chalk": "4.0.0", "chalk": "4.1.0",
"cheerio": "1.0.0-rc.3", "cheerio": "1.0.0-rc.5",
"commander": "5.0.0", "commander": "7.2.0",
"content-disposition": "0.5.3",
"express": "4.17.1", "express": "4.17.1",
"file-type": "14.1.4", "file-type": "16.2.0",
"filenamify": "4.1.0", "filenamify": "4.2.0",
"got": "10.7.0", "got": "11.8.1",
"irc-framework": "4.8.1", "irc-framework": "4.9.0",
"is-utf8": "0.2.1", "is-utf8": "0.2.1",
"ldapjs": "2.0.0-pre.5", "ldapjs": "2.2.3",
"linkify-it": "2.2.0", "linkify-it": "3.0.2",
"lodash": "4.17.15", "lodash": "4.17.20",
"mime-types": "2.1.26", "mime-types": "2.1.28",
"node-forge": "0.9.1", "node-forge": "0.10.0",
"package-json": "6.5.0", "package-json": "6.5.0",
"read": "1.0.7", "read": "1.0.7",
"read-chunk": "3.2.0", "read-chunk": "3.2.0",
"semver": "7.3.2", "semver": "7.3.4",
"socket.io": "2.3.0", "socket.io": "3.1.2",
"tlds": "1.207.0", "tlds": "1.216.0",
"ua-parser-js": "0.7.21", "ua-parser-js": "0.7.23",
"uuid": "7.0.3", "uuid": "8.3.2",
"web-push": "3.4.3", "web-push": "3.4.4",
"yarn": "1.22.4" "yarn": "1.22.10"
}, },
"optionalDependencies": { "optionalDependencies": {
"sqlite3": "4.1.1" "sqlite3": "5.0.2"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.9.0", "@babel/core": "7.13.14",
"@babel/preset-env": "7.9.5", "@babel/preset-env": "7.13.12",
"@fortawesome/fontawesome-free": "5.13.0", "@fortawesome/fontawesome-free": "5.15.3",
"@vue/server-test-utils": "1.0.0-beta.33", "@vue/server-test-utils": "1.1.3",
"@vue/test-utils": "1.0.0-beta.33", "@vue/test-utils": "1.1.3",
"babel-loader": "8.1.0", "babel-loader": "8.2.2",
"babel-plugin-istanbul": "6.0.0", "babel-plugin-istanbul": "6.0.0",
"chai": "4.2.0", "chai": "4.3.4",
"copy-webpack-plugin": "5.1.1", "copy-webpack-plugin": "7.0.0",
"css-loader": "3.5.2", "css-loader": "5.1.1",
"cssnano": "4.1.10", "cssnano": "4.1.10",
"dayjs": "1.8.24", "dayjs": "1.10.4",
"emoji-regex": "9.0.0", "emoji-regex": "9.2.1",
"eslint": "6.8.0", "eslint": "7.23.0",
"eslint-config-prettier": "6.10.1", "eslint-config-prettier": "6.15.0",
"eslint-plugin-vue": "6.2.2", "eslint-plugin-vue": "7.5.0",
"fuzzy": "0.1.3", "fuzzy": "0.1.3",
"graphql-request": "1.8.2", "husky": "4.3.5",
"husky": "4.2.5", "mini-css-extract-plugin": "1.3.6",
"mini-css-extract-plugin": "0.9.0", "mocha": "8.2.1",
"mocha": "7.1.1",
"mousetrap": "1.6.5", "mousetrap": "1.6.5",
"normalize.css": "8.0.1", "normalize.css": "8.0.1",
"npm-run-all": "4.1.5", "npm-run-all": "4.1.5",
"nyc": "15.0.1", "nyc": "15.1.0",
"postcss-import": "12.0.1", "postcss": "8.2.5",
"postcss-loader": "3.0.0", "postcss-import": "14.0.0",
"postcss-loader": "5.0.0",
"postcss-preset-env": "6.7.0", "postcss-preset-env": "6.7.0",
"prettier": "2.0.4", "prettier": "2.2.1",
"pretty-quick": "2.0.1", "pretty-quick": "3.1.0",
"primer-tooltips": "2.0.0", "primer-tooltips": "2.0.0",
"sinon": "9.0.2", "sinon": "9.2.4",
"socket.io-client": "2.3.0", "socket.io-client": "3.1.1",
"stylelint": "13.3.2", "stylelint": "13.9.0",
"stylelint-config-standard": "20.0.0", "stylelint-config-standard": "20.0.0",
"textcomplete": "0.18.1", "textcomplete": "0.18.2",
"undate": "0.3.0", "undate": "0.3.0",
"vue": "2.6.11", "vue": "2.6.12",
"vue-loader": "15.9.1", "vue-loader": "15.9.6",
"vue-router": "3.1.6", "vue-router": "3.5.1",
"vue-server-renderer": "2.6.11", "vue-server-renderer": "2.6.12",
"vue-template-compiler": "2.6.11", "vue-template-compiler": "2.6.12",
"vuedraggable": "2.23.2", "vuedraggable": "2.24.3",
"vuex": "3.2.0", "vuex": "3.6.2",
"webpack": "4.42.1", "webpack": "5.21.2",
"webpack-cli": "3.3.11", "webpack-cli": "4.5.0",
"webpack-dev-middleware": "3.7.2", "webpack-dev-middleware": "4.1.0",
"webpack-hot-middleware": "2.25.0" "webpack-hot-middleware": "2.25.0"
}, },
"husky": { "husky": {

View file

@ -10,8 +10,8 @@
}, },
"packageRules": [ "packageRules": [
{ {
"depTypeList": ["devDependencies"], "depTypeList": ["dependencies", "devDependencies"],
"extends": ["schedule:weekends"] "extends": ["schedule:monthly"]
} }
] ]
} }

View file

@ -49,7 +49,7 @@ const _ = require("lodash");
const colors = require("chalk"); const colors = require("chalk");
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const GraphQLClient = require("graphql-request").GraphQLClient; const got = require("got");
const dayjs = require("dayjs"); const dayjs = require("dayjs");
const semver = require("semver"); const semver = require("semver");
const util = require("util"); const util = require("util");
@ -236,19 +236,31 @@ function fullChangelogUrl(v1, v2) {
// This class is a facade to fetching details about commits / PRs / tags / etc. // This class is a facade to fetching details about commits / PRs / tags / etc.
// for a given repository of our organization. // for a given repository of our organization.
class RepositoryFetcher { class RepositoryFetcher {
// Holds a GraphQLClient and the name of the repository within the // Holds a Github token and repository name
// organization https://github.com/thelounge. constructor(githubToken, repositoryName) {
constructor(graphqlClient, repositoryName) { this.githubToken = githubToken;
this.graphqlClient = graphqlClient;
this.repositoryName = repositoryName; this.repositoryName = repositoryName;
} }
// Base function that actually makes the GraphQL API call // Base function that actually makes the GraphQL API call
async fetch(query, variables = {}) { async fetch(query, variables = {}) {
return this.graphqlClient.request( const response = await got
query, .post("https://api.github.com/graphql", {
Object.assign(variables, {repositoryName: this.repositoryName}) json: {
); query: query,
variables: Object.assign(variables, {repositoryName: this.repositoryName}),
},
headers: {
Authorization: `Bearer ${this.githubToken}`,
},
})
.json();
if (!response.errors && response.data) {
return response.data;
}
throw new Error(`GraphQL request returned no data: ${JSON.stringify(response)}`);
} }
// Returns the git commit that is attached to a given tag // Returns the git commit that is attached to a given tag
@ -789,12 +801,6 @@ function extractContributors(entries) {
return Array.from(set).sort((a, b) => a.localeCompare(b, "en", {sensitivity: "base"})); return Array.from(set).sort((a, b) => a.localeCompare(b, "en", {sensitivity: "base"}));
} }
const client = new GraphQLClient("https://api.github.com/graphql", {
headers: {
Authorization: `bearer ${token}`,
},
});
// Main function. Given a version string (i.e. not a tag!), returns a changelog // Main function. Given a version string (i.e. not a tag!), returns a changelog
// entry and the list of contributors, for both pre-releases and stable // entry and the list of contributors, for both pre-releases and stable
// releases. Templates are located at the top of this file. // releases. Templates are located at the top of this file.
@ -803,7 +809,7 @@ async function generateChangelogEntry(changelog, targetVersion) {
let template; let template;
let contributors = []; let contributors = [];
const codeRepo = new RepositoryFetcher(client, "thelounge"); const codeRepo = new RepositoryFetcher(token, "thelounge");
const previousVersion = await codeRepo.fetchPreviousVersion(targetVersion); const previousVersion = await codeRepo.fetchPreviousVersion(targetVersion);
if (isPrerelease(targetVersion)) { if (isPrerelease(targetVersion)) {
@ -817,7 +823,7 @@ async function generateChangelogEntry(changelog, targetVersion) {
items = parse(codeCommitsAndPullRequests); items = parse(codeCommitsAndPullRequests);
items.milestone = await codeRepo.fetchMilestone(targetVersion); items.milestone = await codeRepo.fetchMilestone(targetVersion);
const websiteRepo = new RepositoryFetcher(client, "thelounge.github.io"); const websiteRepo = new RepositoryFetcher(token, "thelounge.github.io");
const previousWebsiteVersion = await websiteRepo.fetchPreviousVersion(targetVersion); const previousWebsiteVersion = await websiteRepo.fetchPreviousVersion(targetVersion);
const websiteCommitsAndPullRequests = await websiteRepo.fetchCommitsAndPullRequestsSince( const websiteCommitsAndPullRequests = await websiteRepo.fetchCommitsAndPullRequestsSince(
"v" + previousWebsiteVersion "v" + previousWebsiteVersion

View file

@ -19,7 +19,14 @@ const emojiModifiersRegex = /[\u{1f3fb}-\u{1f3ff}]|\u{fe0f}/gu;
const cleanEmoji = emoji.emoji.replace(emojiModifiersRegex, ""); const cleanEmoji = emoji.emoji.replace(emojiModifiersRegex, "");
fullNameEmojiMap[cleanEmoji] = emoji.description; fullNameEmojiMap[cleanEmoji] = emoji.description;
for (const alias of emoji.aliases) { for (let alias of emoji.aliases) {
if (alias !== "-1") {
// Replace dashes to underscores except for :-1:
// This removes autocompletion prompt for :-P
// prompting for :non-potable_water:
alias = alias.replace(/-/g, "_");
}
emojiMap[alias] = emoji.emoji; emojiMap[alias] = emoji.emoji;
} }
} }

View file

@ -28,6 +28,8 @@ const events = [
"ctcp", "ctcp",
"chghost", "chghost",
"error", "error",
"help",
"info",
"invite", "invite",
"join", "join",
"kick", "kick",
@ -60,6 +62,7 @@ function Client(manager, name, config = {}) {
manager: manager, manager: manager,
messageStorage: [], messageStorage: [],
highlightRegex: null, highlightRegex: null,
highlightExceptionRegex: null,
}); });
const client = this; const client = this;
@ -81,15 +84,15 @@ function Client(manager, name, config = {}) {
} }
} }
if (typeof client.config.sessions !== "object") { if (!_.isPlainObject(client.config.sessions)) {
client.config.sessions = {}; client.config.sessions = {};
} }
if (typeof client.config.clientSettings !== "object") { if (!_.isPlainObject(client.config.clientSettings)) {
client.config.clientSettings = {}; client.config.clientSettings = {};
} }
if (typeof client.config.browser !== "object") { if (!_.isPlainObject(client.config.browser)) {
client.config.browser = {}; client.config.browser = {};
} }
@ -238,6 +241,7 @@ Client.prototype.connect = function (args, isStartup = false) {
nick: String(args.nick || ""), nick: String(args.nick || ""),
username: String(args.username || ""), username: String(args.username || ""),
realname: String(args.realname || ""), realname: String(args.realname || ""),
leaveMessage: String(args.leaveMessage || ""),
sasl: String(args.sasl || ""), sasl: String(args.sasl || ""),
saslAccount: String(args.saslAccount || ""), saslAccount: String(args.saslAccount || ""),
saslPassword: String(args.saslPassword || ""), saslPassword: String(args.saslPassword || ""),
@ -422,30 +426,32 @@ Client.prototype.inputLine = function (data) {
}; };
Client.prototype.compileCustomHighlights = function () { Client.prototype.compileCustomHighlights = function () {
const client = this; this.highlightRegex = compileHighlightRegex(this.config.clientSettings.highlights);
this.highlightExceptionRegex = compileHighlightRegex(
this.config.clientSettings.highlightExceptions
);
};
if (typeof client.config.clientSettings.highlights !== "string") { function compileHighlightRegex(customHighlightString) {
client.highlightRegex = null; if (typeof customHighlightString !== "string") {
return; return null;
} }
// Ensure we don't have empty string in the list of highlights // Ensure we don't have empty strings in the list of highlights
// otherwise, users get notifications for everything const highlightsTokens = customHighlightString
const highlightsTokens = client.config.clientSettings.highlights
.split(",") .split(",")
.map((highlight) => escapeRegExp(highlight.trim())) .map((highlight) => escapeRegExp(highlight.trim()))
.filter((highlight) => highlight.length > 0); .filter((highlight) => highlight.length > 0);
if (highlightsTokens.length === 0) { if (highlightsTokens.length === 0) {
client.highlightRegex = null; return null;
return;
} }
client.highlightRegex = new RegExp( return new RegExp(
`(?:^|[ .,+!?|/:<>(){}'"@&~-])(?:${highlightsTokens.join("|")})(?:$|[ .,+!?|/:<>(){}'"-])`, `(?:^|[ .,+!?|/:<>(){}'"@&~-])(?:${highlightsTokens.join("|")})(?:$|[ .,+!?|/:<>(){}'"-])`,
"i" "i"
); );
}; }
Client.prototype.more = function (data) { Client.prototype.more = function (data) {
const client = this; const client = this;
@ -632,11 +638,11 @@ Client.prototype.names = function (data) {
Client.prototype.quit = function (signOut) { Client.prototype.quit = function (signOut) {
const sockets = this.manager.sockets.sockets; const sockets = this.manager.sockets.sockets;
const room = sockets.adapter.rooms[this.id]; const room = sockets.adapter.rooms.get(this.id);
if (room && room.sockets) { if (room) {
for (const user in room.sockets) { for (const user of room) {
const socket = sockets.connected[user]; const socket = sockets.sockets.get(user);
if (socket) { if (socket) {
if (signOut) { if (signOut) {
@ -649,7 +655,7 @@ Client.prototype.quit = function (signOut) {
} }
this.networks.forEach((network) => { this.networks.forEach((network) => {
network.quit(Helper.config.leaveMessage); network.quit();
network.destroy(); network.destroy();
}); });

View file

@ -34,18 +34,40 @@ ClientManager.prototype.init = function (identHandler, sockets) {
}; };
ClientManager.prototype.findClient = function (name) { ClientManager.prototype.findClient = function (name) {
return this.clients.find((u) => u.name === name); name = name.toLowerCase();
return this.clients.find((u) => u.name.toLowerCase() === name);
}; };
ClientManager.prototype.loadUsers = function () { ClientManager.prototype.loadUsers = function () {
const users = this.getUsers(); let users = this.getUsers();
if (users.length === 0) { if (users.length === 0) {
log.info( log.info(
`There are currently no users. Create one with ${colors.bold("thelounge add <name>")}.` `There are currently no users. Create one with ${colors.bold("thelounge add <name>")}.`
); );
return;
} }
const alreadySeenUsers = new Set();
users = users.filter((user) => {
user = user.toLowerCase();
if (alreadySeenUsers.has(user)) {
log.error(
`There is more than one user named "${colors.bold(
user
)}". Usernames are now case insensitive, duplicate users will not load.`
);
return false;
}
alreadySeenUsers.add(user);
return true;
});
// This callback is used by Auth plugins to load users they deem acceptable // This callback is used by Auth plugins to load users they deem acceptable
const callbackLoadUser = (user) => { const callbackLoadUser = (user) => {
this.loadUser(user); this.loadUser(user);

View file

@ -2,6 +2,7 @@
const log = require("../log"); const log = require("../log");
const colors = require("chalk"); const colors = require("chalk");
const semver = require("semver");
const program = require("commander"); const program = require("commander");
const Helper = require("../helper"); const Helper = require("../helper");
const Utils = require("./utils"); const Utils = require("./utils");
@ -40,6 +41,21 @@ program
process.exit(1); process.exit(1);
} }
if (
json.thelounge.supports &&
!semver.satisfies(Helper.getVersionNumber(), json.thelounge.supports)
) {
log.error(
`${colors.red(
json.name + " v" + json.version
)} does not support The Lounge v${Helper.getVersionNumber()}. Supported version(s): ${
json.thelounge.supports
}`
);
process.exit(2);
}
log.info(`Installing ${colors.green(json.name + " v" + json.version)}...`); log.info(`Installing ${colors.green(json.name + " v" + json.version)}...`);
return Utils.executeYarnCommand("add", "--exact", `${json.name}@${json.version}`) return Utils.executeYarnCommand("add", "--exact", `${json.name}@${json.version}`)

View file

@ -11,7 +11,9 @@ program
.command("add <name>") .command("add <name>")
.description("Add a new user") .description("Add a new user")
.on("--help", Utils.extraHelp) .on("--help", Utils.extraHelp)
.action(function (name) { .option("--password [password]", "new password, will be prompted if not specified")
.option("--save-logs", "if password is specified, this enables saving logs to disk")
.action(function (name, cmdObj) {
if (!fs.existsSync(Helper.getUsersPath())) { if (!fs.existsSync(Helper.getUsersPath())) {
log.error(`${Helper.getUsersPath()} does not exist.`); log.error(`${Helper.getUsersPath()} does not exist.`);
return; return;
@ -31,6 +33,11 @@ program
return; return;
} }
if (cmdObj.password) {
add(manager, name, cmdObj.password, !!cmdObj.saveLogs);
return;
}
log.prompt( log.prompt(
{ {
text: "Enter password:", text: "Enter password:",

View file

@ -11,7 +11,8 @@ program
.command("reset <name>") .command("reset <name>")
.description("Reset user password") .description("Reset user password")
.on("--help", Utils.extraHelp) .on("--help", Utils.extraHelp)
.action(function (name) { .option("--password [password]", "new password, will be prompted if not specified")
.action(function (name, cmdObj) {
if (!fs.existsSync(Helper.getUsersPath())) { if (!fs.existsSync(Helper.getUsersPath())) {
log.error(`${Helper.getUsersPath()} does not exist.`); log.error(`${Helper.getUsersPath()} does not exist.`);
return; return;
@ -30,9 +31,10 @@ program
return; return;
} }
const pathReal = Helper.getUserConfigPath(name); if (cmdObj.password) {
const pathTemp = pathReal + ".tmp"; change(name, cmdObj.password);
const user = JSON.parse(fs.readFileSync(pathReal, "utf-8")); return;
}
log.prompt( log.prompt(
{ {
@ -44,17 +46,25 @@ program
return; return;
} }
user.password = Helper.password.hash(password); change(name, password);
user.sessions = {};
const newUser = JSON.stringify(user, null, "\t");
// Write to a temp file first, in case the write fails
// we do not lose the original file (for example when disk is full)
fs.writeFileSync(pathTemp, newUser);
fs.renameSync(pathTemp, pathReal);
log.info(`Successfully reset password for ${colors.bold(name)}.`);
} }
); );
}); });
function change(name, password) {
const pathReal = Helper.getUserConfigPath(name);
const pathTemp = pathReal + ".tmp";
const user = JSON.parse(fs.readFileSync(pathReal, "utf-8"));
user.password = Helper.password.hash(password);
user.sessions = {};
const newUser = JSON.stringify(user, null, "\t");
// Write to a temp file first, in case the write fails
// we do not lose the original file (for example when disk is full)
fs.writeFileSync(pathTemp, newUser);
fs.renameSync(pathTemp, pathReal);
log.info(`Successfully reset password for ${colors.bold(name)}.`);
}

View file

@ -36,6 +36,7 @@ const Helper = {
setHome, setHome,
getVersion, getVersion,
getVersionCacheBust, getVersionCacheBust,
getVersionNumber,
getGitCommit, getGitCommit,
ip2hex, ip2hex,
mergeConfig, mergeConfig,
@ -60,6 +61,10 @@ function getVersion() {
return gitCommit ? `source (${gitCommit} / ${version})` : version; return gitCommit ? `source (${gitCommit} / ${version})` : version;
} }
function getVersionNumber() {
return pkg.version;
}
let _gitCommit; let _gitCommit;
function getGitCommit() { function getGitCommit() {

View file

@ -43,7 +43,7 @@ class Msg {
} }
return ( return (
this.type !== Msg.Type.MOTD && this.type !== Msg.Type.MONOSPACE_BLOCK &&
this.type !== Msg.Type.ERROR && this.type !== Msg.Type.ERROR &&
this.type !== Msg.Type.TOPIC_SET_BY && this.type !== Msg.Type.TOPIC_SET_BY &&
this.type !== Msg.Type.MODE_CHANNEL && this.type !== Msg.Type.MODE_CHANNEL &&
@ -66,7 +66,7 @@ Msg.Type = {
MESSAGE: "message", MESSAGE: "message",
MODE: "mode", MODE: "mode",
MODE_CHANNEL: "mode_channel", MODE_CHANNEL: "mode_channel",
MOTD: "motd", MONOSPACE_BLOCK: "monospace_block",
NICK: "nick", NICK: "nick",
NOTICE: "notice", NOTICE: "notice",
PART: "part", PART: "part",

View file

@ -35,6 +35,7 @@ function Network(attr) {
commands: [], commands: [],
username: "", username: "",
realname: "", realname: "",
leaveMessage: "",
sasl: "", sasl: "",
saslAccount: "", saslAccount: "",
saslPassword: "", saslPassword: "",
@ -82,6 +83,7 @@ Network.prototype.validate = function (client) {
this.username = cleanString(this.username) || "thelounge"; this.username = cleanString(this.username) || "thelounge";
this.realname = cleanString(this.realname) || "The Lounge User"; this.realname = cleanString(this.realname) || "The Lounge User";
this.leaveMessage = cleanString(this.leaveMessage);
this.password = cleanString(this.password); this.password = cleanString(this.password);
this.host = cleanString(this.host).toLowerCase(); this.host = cleanString(this.host).toLowerCase();
this.name = cleanString(this.name); this.name = cleanString(this.name);
@ -120,7 +122,12 @@ Network.prototype.validate = function (client) {
return false; return false;
} }
this.name = Helper.config.defaults.name; if (Helper.config.public) {
this.name = Helper.config.defaults.name;
// Sync lobby channel name
this.channels[0].name = Helper.config.defaults.name;
}
this.host = Helper.config.defaults.host; this.host = Helper.config.defaults.host;
this.port = Helper.config.defaults.port; this.port = Helper.config.defaults.port;
this.tls = Helper.config.defaults.tls; this.tls = Helper.config.defaults.tls;
@ -168,8 +175,10 @@ Network.prototype.createIrcFramework = function (client) {
enable_echomessage: true, enable_echomessage: true,
enable_setname: true, enable_setname: true,
auto_reconnect: true, 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 // Exponential backoff maxes out at 300 seconds after 9 reconnects,
// it will keep trying for well over an hour (plus the timeouts)
auto_reconnect_max_retries: 30,
}); });
this.setIrcFrameworkOptions(client); this.setIrcFrameworkOptions(client);
@ -197,8 +206,7 @@ Network.prototype.setIrcFrameworkOptions = function (client) {
this.irc.options.tls = this.tls; this.irc.options.tls = this.tls;
this.irc.options.rejectUnauthorized = this.rejectUnauthorized; this.irc.options.rejectUnauthorized = this.rejectUnauthorized;
this.irc.options.webirc = this.createWebIrc(client); this.irc.options.webirc = this.createWebIrc(client);
this.irc.options.client_certificate = null;
this.irc.options.client_certificate = this.tls ? ClientCertificate.get(this.uuid) : null;
if (!this.sasl) { if (!this.sasl) {
delete this.irc.options.sasl_mechanism; delete this.irc.options.sasl_mechanism;
@ -206,6 +214,7 @@ Network.prototype.setIrcFrameworkOptions = function (client) {
} else if (this.sasl === "external") { } else if (this.sasl === "external") {
this.irc.options.sasl_mechanism = "EXTERNAL"; this.irc.options.sasl_mechanism = "EXTERNAL";
this.irc.options.account = {}; this.irc.options.account = {};
this.irc.options.client_certificate = ClientCertificate.get(this.uuid);
} else if (this.sasl === "plain") { } else if (this.sasl === "plain") {
delete this.irc.options.sasl_mechanism; delete this.irc.options.sasl_mechanism;
this.irc.options.account = { this.irc.options.account = {
@ -246,6 +255,7 @@ Network.prototype.createWebIrc = function (client) {
}; };
Network.prototype.edit = function (client, args) { Network.prototype.edit = function (client, args) {
const oldNetworkName = this.name;
const oldNick = this.nick; const oldNick = this.nick;
const oldRealname = this.realname; const oldRealname = this.realname;
@ -259,6 +269,7 @@ Network.prototype.edit = function (client, args) {
this.password = String(args.password || ""); this.password = String(args.password || "");
this.username = String(args.username || ""); this.username = String(args.username || "");
this.realname = String(args.realname || ""); this.realname = String(args.realname || "");
this.leaveMessage = String(args.leaveMessage || "");
this.sasl = String(args.sasl || ""); this.sasl = String(args.sasl || "");
this.saslAccount = String(args.saslAccount || ""); this.saslAccount = String(args.saslAccount || "");
this.saslPassword = String(args.saslPassword || ""); this.saslPassword = String(args.saslPassword || "");
@ -272,6 +283,14 @@ Network.prototype.edit = function (client, args) {
// Sync lobby channel name // Sync lobby channel name
this.channels[0].name = this.name; this.channels[0].name = this.name;
if (this.name !== oldNetworkName) {
// Send updated network name to all connected clients
client.emit("network:name", {
uuid: this.uuid,
name: this.name,
});
}
if (!this.validate(client)) { if (!this.validate(client)) {
return; return;
} }
@ -420,7 +439,7 @@ Network.prototype.quit = function (quitMessage) {
// https://ircv3.net/specs/extensions/sts#rescheduling-expiry-on-disconnect // https://ircv3.net/specs/extensions/sts#rescheduling-expiry-on-disconnect
STSPolicies.refreshExpiration(this.host); STSPolicies.refreshExpiration(this.host);
this.irc.quit(quitMessage || Helper.config.leaveMessage); this.irc.quit(quitMessage || this.leaveMessage || Helper.config.leaveMessage);
}; };
Network.prototype.exportForEdit = function () { Network.prototype.exportForEdit = function () {
@ -431,6 +450,7 @@ Network.prototype.exportForEdit = function () {
"password", "password",
"username", "username",
"realname", "realname",
"leaveMessage",
"sasl", "sasl",
"saslAccount", "saslAccount",
"saslPassword", "saslPassword",
@ -465,6 +485,7 @@ Network.prototype.export = function () {
"password", "password",
"username", "username",
"realname", "realname",
"leaveMessage",
"sasl", "sasl",
"saslAccount", "saslAccount",
"saslPassword", "saslPassword",

View file

@ -8,25 +8,28 @@ function User(attr, prefixLookup) {
_.defaults(this, attr, { _.defaults(this, attr, {
modes: [], modes: [],
away: "", away: "",
mode: "",
nick: "", nick: "",
lastMessage: 0, lastMessage: 0,
}); });
Object.defineProperty(this, "mode", {
get() {
return this.modes[0] || "";
},
});
this.setModes(this.modes, prefixLookup); this.setModes(this.modes, prefixLookup);
} }
User.prototype.setModes = function (modes, prefixLookup) { User.prototype.setModes = function (modes, prefixLookup) {
// irc-framework sets character mode, but The Lounge works with symbols // irc-framework sets character mode, but The Lounge works with symbols
this.modes = modes.map((mode) => prefixLookup[mode]); this.modes = modes.map((mode) => prefixLookup[mode]);
this.mode = this.modes[0] || "";
}; };
User.prototype.toJSON = function () { User.prototype.toJSON = function () {
return { return {
nick: this.nick, nick: this.nick,
mode: this.mode, modes: this.modes,
lastMessage: this.lastMessage, lastMessage: this.lastMessage,
}; };
}; };

View file

@ -13,19 +13,6 @@ module.exports = (app) => {
"webpack-hot-middleware/client?path=storage/__webpack_hmr" "webpack-hot-middleware/client?path=storage/__webpack_hmr"
); );
// Enable hot module reload support in mini-css-extract-plugin
for (const rule of webpackConfig.module.rules) {
if (!Array.isArray(rule.use)) {
continue;
}
for (const use of rule.use) {
if (use.options && typeof use.options.hmr !== "undefined") {
use.options.hmr = true;
}
}
}
const compiler = webpack(webpackConfig); const compiler = webpack(webpackConfig);
app.use( app.use(

View file

@ -48,7 +48,7 @@ exports.input = function (network, chan, cmd, args) {
}); });
this.save(); this.save();
} else { } else {
const partMessage = args.join(" ") || Helper.config.leaveMessage; const partMessage = args.join(" ") || network.leaveMessage || Helper.config.leaveMessage;
network.irc.part(target.name, partMessage); network.irc.part(target.name, partMessage);
} }

View file

@ -178,14 +178,9 @@ module.exports = function (irc, network) {
network.channels[0].pushMessage( network.channels[0].pushMessage(
client, client,
new Msg({ new Msg({
text: text: `Disconnected from the network. Reconnecting in ${Math.round(
"Disconnected from the network. Reconnecting in " + data.wait / 1000
Math.round(data.wait / 1000) + )} seconds (Attempt ${data.attempt})`,
" seconds… (Attempt " +
data.attempt +
" of " +
data.max_retries +
")",
}), }),
true true
); );

View file

@ -8,7 +8,7 @@ const pkg = require("../../../package.json");
const ctcpResponses = { const ctcpResponses = {
CLIENTINFO: () => CLIENTINFO: () =>
Object.getOwnPropertyNames(ctcpResponses) // TODO: This is currently handled by irc-framework Object.getOwnPropertyNames(ctcpResponses)
.filter((key) => key !== "CLIENTINFO" && typeof ctcpResponses[key] === "function") .filter((key) => key !== "CLIENTINFO" && typeof ctcpResponses[key] === "function")
.join(" "), .join(" "),
PING: ({message}) => message.substring(5), PING: ({message}) => message.substring(5),
@ -67,17 +67,18 @@ module.exports = function (irc, network) {
return; return;
} }
const target = data.from_server ? data.hostname : data.nick;
const response = ctcpResponses[data.type]; const response = ctcpResponses[data.type];
if (response) { if (response) {
irc.ctcpResponse(data.nick, data.type, response(data)); irc.ctcpResponse(target, data.type, response(data));
} }
// Let user know someone is making a CTCP request against their nick // Let user know someone is making a CTCP request against their nick
const msg = new Msg({ const msg = new Msg({
type: Msg.Type.CTCP_REQUEST, type: Msg.Type.CTCP_REQUEST,
time: data.time, time: data.time,
from: new User({nick: data.nick}), from: new User({nick: target}),
hostmask: data.ident + "@" + data.hostname, hostmask: data.ident + "@" + data.hostname,
ctcpMessage: data.message, ctcpMessage: data.message,
}); });

View file

@ -0,0 +1,20 @@
"use strict";
const Msg = require("../../models/msg");
module.exports = function (irc, network) {
const client = this;
irc.on("help", function (data) {
const lobby = network.channels[0];
if (data.help) {
const msg = new Msg({
type: Msg.Type.MONOSPACE_BLOCK,
command: "help",
text: data.help,
});
lobby.pushMessage(client, msg, true);
}
});
};

View file

@ -0,0 +1,20 @@
"use strict";
const Msg = require("../../models/msg");
module.exports = function (irc, network) {
const client = this;
irc.on("info", function (data) {
const lobby = network.channels[0];
if (data.info) {
const msg = new Msg({
type: Msg.Type.MONOSPACE_BLOCK,
command: "info",
text: data.info,
});
lobby.pushMessage(client, msg, true);
}
});
};

View file

@ -41,6 +41,8 @@ module.exports = function (irc, network) {
time: data.time, time: data.time,
from: user, from: user,
hostmask: data.ident + "@" + data.hostname, hostmask: data.ident + "@" + data.hostname,
gecos: data.gecos,
account: data.account,
type: Msg.Type.JOIN, type: Msg.Type.JOIN,
self: data.nick === irc.user.nick, self: data.nick === irc.user.nick,
}); });

View file

@ -5,22 +5,18 @@ const got = require("got");
const URL = require("url").URL; const URL = require("url").URL;
const mime = require("mime-types"); const mime = require("mime-types");
const Helper = require("../../helper"); const Helper = require("../../helper");
const cleanIrcMessage = require("../../../client/js/helpers/ircmessageparser/cleanIrcMessage"); const {findLinksWithSchema} = require("../../../client/js/helpers/ircmessageparser/findLinks");
const findLinks = require("../../../client/js/helpers/ircmessageparser/findLinks");
const storage = require("../storage"); const storage = require("../storage");
const currentFetchPromises = new Map(); const currentFetchPromises = new Map();
const imageTypeRegex = /^image\/.+/; const imageTypeRegex = /^image\/.+/;
const mediaTypeRegex = /^(audio|video)\/.+/; const mediaTypeRegex = /^(audio|video)\/.+/;
module.exports = function (client, chan, msg) { module.exports = function (client, chan, msg, cleanText) {
if (!Helper.config.prefetch) { if (!Helper.config.prefetch) {
return; return;
} }
// Remove all IRC formatting characters before searching for links msg.previews = findLinksWithSchema(cleanText).reduce((cleanLinks, link) => {
const cleanText = cleanIrcMessage(msg.text);
msg.previews = findLinks(cleanText).reduce((cleanLinks, link) => {
const url = normalizeURL(link.link); const url = normalizeURL(link.link);
// If the URL is invalid and cannot be normalized, don't fetch it // If the URL is invalid and cannot be normalized, don't fetch it
@ -84,11 +80,6 @@ function parseHtml(preview, res, client) {
$('meta[property="og:description"]').attr("content") || $('meta[property="og:description"]').attr("content") ||
$('meta[name="description"]').attr("content") || $('meta[name="description"]').attr("content") ||
""; "";
let thumb =
$('meta[property="og:image"]').attr("content") ||
$('meta[name="twitter:image:src"]').attr("content") ||
$('link[rel="image_src"]').attr("href") ||
"";
if (preview.head.length) { if (preview.head.length) {
preview.head = preview.head.substr(0, 100); preview.head = preview.head.substr(0, 100);
@ -98,6 +89,17 @@ function parseHtml(preview, res, client) {
preview.body = preview.body.substr(0, 300); preview.body = preview.body.substr(0, 300);
} }
if (!Helper.config.prefetchStorage && Helper.config.disableMediaPreview) {
resolve(res);
return;
}
let thumb =
$('meta[property="og:image"]').attr("content") ||
$('meta[name="twitter:image:src"]').attr("content") ||
$('link[rel="image_src"]').attr("href") ||
"";
// Make sure thumbnail is a valid and absolute url // Make sure thumbnail is a valid and absolute url
if (thumb.length) { if (thumb.length) {
thumb = normalizeURL(thumb, preview.link) || ""; thumb = normalizeURL(thumb, preview.link) || "";
@ -127,7 +129,25 @@ function parseHtml(preview, res, client) {
function parseHtmlMedia($, preview, client) { function parseHtmlMedia($, preview, client) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (Helper.config.disableMediaPreview) {
reject();
return;
}
let foundMedia = false; let foundMedia = false;
const openGraphType = $('meta[property="og:type"]').attr("content");
// Certain news websites may include video and audio tags,
// despite actually being an article (as indicated by og:type).
// If there is og:type tag, we will only select video or audio if it matches
if (
openGraphType &&
!openGraphType.startsWith("video") &&
!openGraphType.startsWith("music")
) {
reject();
return;
}
["video", "audio"].forEach((type) => { ["video", "audio"].forEach((type) => {
if (foundMedia) { if (foundMedia) {
@ -203,6 +223,11 @@ function parse(msg, chan, preview, res, client) {
case "image/jpg": case "image/jpg":
case "image/jpeg": case "image/jpeg":
case "image/webp": case "image/webp":
case "image/avif":
if (!Helper.config.prefetchStorage && Helper.config.disableMediaPreview) {
return removePreview(msg, preview);
}
if (res.size > Helper.config.prefetchMaxImageSize * 1024) { if (res.size > Helper.config.prefetchMaxImageSize * 1024) {
preview.type = "error"; preview.type = "error";
preview.error = "image-too-big"; preview.error = "image-too-big";
@ -228,6 +253,10 @@ function parse(msg, chan, preview, res, client) {
break; break;
} }
if (Helper.config.disableMediaPreview) {
return removePreview(msg, preview);
}
preview.type = "audio"; preview.type = "audio";
preview.media = preview.link; preview.media = preview.link;
preview.mediaType = res.type; preview.mediaType = res.type;
@ -241,6 +270,10 @@ function parse(msg, chan, preview, res, client) {
break; break;
} }
if (Helper.config.disableMediaPreview) {
return removePreview(msg, preview);
}
preview.type = "video"; preview.type = "video";
preview.media = preview.link; preview.media = preview.link;
preview.mediaType = res.type; preview.mediaType = res.type;
@ -354,7 +387,9 @@ function fetch(uri, headers) {
retry: 0, retry: 0,
timeout: 5000, timeout: 5000,
headers: getRequestHeaders(headers), headers: getRequestHeaders(headers),
rejectUnauthorized: false, https: {
rejectUnauthorized: false,
},
}); });
gotStream gotStream
@ -372,9 +407,14 @@ function fetch(uri, headers) {
// We don't need to download the file any further after we received content-type header // We don't need to download the file any further after we received content-type header
gotStream.destroy(); gotStream.destroy();
} else { } else {
// if not image, limit download to 50kb, since we need only meta tags // if not image, limit download to the max search size, since we need only meta tags
// twitter.com sends opengraph meta tags within ~20kb of data for individual tweets // twitter.com sends opengraph meta tags within ~20kb of data for individual tweets, the default is set to 50.
limit = 1024 * 50; // for sites like Youtube the og tags are in the first 300K and hence this is configurable by the admin
limit =
"prefetchMaxSearchSize" in Helper.config
? Helper.config.prefetchMaxSearchSize * 1024
: // set to the previous size if config option is unset
50 * 1024;
} }
}) })
.on("error", (e) => reject(e)) .on("error", (e) => reject(e))

View file

@ -115,6 +115,9 @@ module.exports = function (irc, network) {
msg.showInActive = true; msg.showInActive = true;
} }
// remove IRC formatting for custom highlight testing
const cleanMessage = cleanIrcMessage(data.message);
// Self messages in channels are never highlighted // Self messages in channels are never highlighted
// Non-self messages are highlighted as soon as the nick is detected // Non-self messages are highlighted as soon as the nick is detected
if (!msg.highlight && !msg.self) { if (!msg.highlight && !msg.self) {
@ -122,10 +125,19 @@ module.exports = function (irc, network) {
// If we still don't have a highlight, test against custom highlights if there's any // If we still don't have a highlight, test against custom highlights if there's any
if (!msg.highlight && client.highlightRegex) { if (!msg.highlight && client.highlightRegex) {
msg.highlight = client.highlightRegex.test(data.message); msg.highlight = client.highlightRegex.test(cleanMessage);
} }
} }
// if highlight exceptions match, do not highlight at all
if (msg.highlight && client.highlightExceptionRegex) {
msg.highlight = !client.highlightExceptionRegex.test(cleanMessage);
}
if (data.group) {
msg.statusmsgGroup = data.group;
}
let match; let match;
while ((match = nickRegExp.exec(data.message))) { while ((match = nickRegExp.exec(data.message))) {
@ -136,7 +148,7 @@ module.exports = function (irc, network) {
// No prefetch URLs unless are simple MESSAGE or ACTION types // No prefetch URLs unless are simple MESSAGE or ACTION types
if ([Msg.Type.MESSAGE, Msg.Type.ACTION].includes(data.type)) { if ([Msg.Type.MESSAGE, Msg.Type.ACTION].includes(data.type)) {
LinkPrefetch(client, chan, msg); LinkPrefetch(client, chan, msg, cleanMessage);
} }
chan.pushMessage(client, msg, !msg.self); chan.pushMessage(client, msg, !msg.self);
@ -144,7 +156,7 @@ module.exports = function (irc, network) {
// Do not send notifications for messages older than 15 minutes (znc buffer for example) // Do not send notifications for messages older than 15 minutes (znc buffer for example)
if (msg.highlight && (!data.time || data.time > Date.now() - 900000)) { if (msg.highlight && (!data.time || data.time > Date.now() - 900000)) {
let title = chan.name; let title = chan.name;
let body = cleanIrcMessage(data.message); let body = cleanMessage;
if (msg.type === Msg.Type.ACTION) { if (msg.type === Msg.Type.ACTION) {
// For actions, do not include colon in the message // For actions, do not include colon in the message

View file

@ -60,16 +60,16 @@ module.exports = function (irc, network) {
self: data.nick === irc.user.nick, self: data.nick === irc.user.nick,
}); });
for (const param of data.raw_params) { const users = [];
const users = [];
for (const param of data.raw_params) {
if (targetChan.findUser(param)) { if (targetChan.findUser(param)) {
users.push(param); users.push(param);
} }
}
if (users.length > 0) { if (users.length > 0) {
msg.users = users; msg.users = users;
}
} }
targetChan.pushMessage(client, msg); targetChan.pushMessage(client, msg);
@ -117,9 +117,6 @@ module.exports = function (irc, network) {
return userModeSortPriority[a] - userModeSortPriority[b]; return userModeSortPriority[a] - userModeSortPriority[b];
}); });
} }
// TODO: remove in future
user.mode = (user.modes && user.modes[0]) || "";
}); });
if (!usersUpdated) { if (!usersUpdated) {

View file

@ -10,7 +10,8 @@ module.exports = function (irc, network) {
if (data.motd) { if (data.motd) {
const msg = new Msg({ const msg = new Msg({
type: Msg.Type.MOTD, type: Msg.Type.MONOSPACE_BLOCK,
command: "motd",
text: data.motd, text: data.motd,
}); });
lobby.pushMessage(client, msg); lobby.pushMessage(client, msg);
@ -18,7 +19,8 @@ module.exports = function (irc, network) {
if (data.error) { if (data.error) {
const msg = new Msg({ const msg = new Msg({
type: Msg.Type.MOTD, type: Msg.Type.MONOSPACE_BLOCK,
command: "motd",
text: data.error, text: data.error,
}); });
lobby.pushMessage(client, msg); lobby.pushMessage(client, msg);

View file

@ -183,18 +183,16 @@ class MessageStorage {
} }
resolve( resolve(
rows rows.reverse().map((row) => {
.map((row) => { const msg = JSON.parse(row.msg);
const msg = JSON.parse(row.msg); msg.time = row.time;
msg.time = row.time; msg.type = row.type;
msg.type = row.type;
const newMsg = new Msg(msg); const newMsg = new Msg(msg);
newMsg.id = this.client.idMsg++; newMsg.id = this.client.idMsg++;
return newMsg; return newMsg;
}) })
.reverse()
); );
} }
) )

View file

@ -4,6 +4,7 @@ const _ = require("lodash");
const log = require("../../log"); const log = require("../../log");
const colors = require("chalk"); const colors = require("chalk");
const path = require("path"); const path = require("path");
const semver = require("semver");
const Helper = require("../../helper"); const Helper = require("../../helper");
const themes = require("./themes"); const themes = require("./themes");
const packageMap = new Map(); const packageMap = new Map();
@ -93,6 +94,13 @@ function loadPackage(packageName) {
throw "'thelounge' is not present in package.json"; throw "'thelounge' is not present in package.json";
} }
if (
packageInfo.thelounge.supports &&
!semver.satisfies(Helper.getVersionNumber(), packageInfo.thelounge.supports)
) {
throw `v${packageInfo.version} does not support this version of The Lounge. Supports: ${packageInfo.thelounge.supports}`;
}
packageFile = require(packagePath); packageFile = require(packagePath);
} catch (e) { } catch (e) {
log.error(`Package ${colors.bold(packageName)} could not be loaded: ${colors.red(e)}`); log.error(`Package ${colors.bold(packageName)} could not be loaded: ${colors.red(e)}`);

View file

@ -10,23 +10,28 @@ const readChunk = require("read-chunk");
const crypto = require("crypto"); const crypto = require("crypto");
const isUtf8 = require("is-utf8"); const isUtf8 = require("is-utf8");
const log = require("../log"); const log = require("../log");
const contentDisposition = require("content-disposition");
const whitelist = [ // Map of allowed mime types to their respecive default filenames
"application/ogg", // that will be rendered in browser without forcing them to be downloaded
"audio/midi", const inlineContentDispositionTypes = {
"audio/mpeg", "application/ogg": "media.ogx",
"audio/ogg", "audio/midi": "audio.midi",
"audio/vnd.wave", "audio/mpeg": "audio.mp3",
"image/bmp", "audio/ogg": "audio.ogg",
"image/gif", "audio/vnd.wave": "audio.wav",
"image/jpeg", "audio/flac": "audio.flac",
"image/png", "image/bmp": "image.bmp",
"image/webp", "image/gif": "image.gif",
"text/plain", "image/jpeg": "image.jpg",
"video/mp4", "image/png": "image.png",
"video/ogg", "image/webp": "image.webp",
"video/webm", "image/avif": "image.avif",
]; "text/plain": "text.txt",
"video/mp4": "video.mp4",
"video/ogg": "video.ogv",
"video/webm": "video.webm",
};
const uploadTokens = new Map(); const uploadTokens = new Map();
@ -35,17 +40,33 @@ class Uploader {
socket.on("upload:auth", () => { socket.on("upload:auth", () => {
const token = uuidv4(); const token = uuidv4();
uploadTokens.set(token, true);
socket.emit("upload:auth", token); socket.emit("upload:auth", token);
// Invalidate the token in one minute // Invalidate the token in one minute
setTimeout(() => uploadTokens.delete(token), 60 * 1000); const timeout = Uploader.createTokenTimeout(token);
uploadTokens.set(token, timeout);
});
socket.on("upload:ping", (token) => {
if (typeof token !== "string") {
return;
}
let timeout = uploadTokens.get(token);
if (!timeout) {
return;
}
clearTimeout(timeout);
timeout = Uploader.createTokenTimeout(token);
uploadTokens.set(token, timeout);
}); });
} }
static isValidType(mimeType) { static createTokenTimeout(token) {
return whitelist.includes(mimeType); return setTimeout(() => uploadTokens.delete(token), 60 * 1000);
} }
static router(express) { static router(express) {
@ -72,8 +93,21 @@ class Uploader {
return res.status(404).send("Not found"); return res.status(404).send("Not found");
} }
// Force a download in the browser if it's not a whitelisted type (binary or otherwise unknown) // Force a download in the browser if it's not an allowed type (binary or otherwise unknown)
const contentDisposition = Uploader.isValidType(detectedMimeType) ? "inline" : "attachment"; let slug = req.params.slug;
const isInline = detectedMimeType in inlineContentDispositionTypes;
let disposition = isInline ? "inline" : "attachment";
if (!slug && isInline) {
slug = inlineContentDispositionTypes[detectedMimeType];
}
if (slug) {
disposition = contentDisposition(slug.trim(), {
fallback: false,
type: disposition,
});
}
if (detectedMimeType === "audio/vnd.wave") { if (detectedMimeType === "audio/vnd.wave") {
// Send a more common mime type for wave audio files // Send a more common mime type for wave audio files
@ -81,7 +115,7 @@ class Uploader {
detectedMimeType = "audio/wav"; detectedMimeType = "audio/wav";
} }
res.setHeader("Content-Disposition", contentDisposition); res.setHeader("Content-Disposition", disposition);
res.setHeader("Cache-Control", "max-age=86400"); res.setHeader("Cache-Control", "max-age=86400");
res.contentType(detectedMimeType); res.contentType(detectedMimeType);

View file

@ -167,6 +167,7 @@ module.exports = function (options = {}) {
cookie: false, cookie: false,
serveClient: false, serveClient: false,
transports: Helper.config.transports, transports: Helper.config.transports,
pingTimeout: 60000,
}); });
sockets.on("connect", (socket) => { sockets.on("connect", (socket) => {
@ -363,13 +364,13 @@ function initializeClient(socket, client, token, lastMessage, openChannel) {
}); });
socket.on("input", (data) => { socket.on("input", (data) => {
if (typeof data === "object") { if (_.isPlainObject(data)) {
client.input(data); client.input(data);
} }
}); });
socket.on("more", (data) => { socket.on("more", (data) => {
if (typeof data === "object") { if (_.isPlainObject(data)) {
const history = client.more(data); const history = client.more(data);
if (history !== null) { if (history !== null) {
@ -379,7 +380,7 @@ function initializeClient(socket, client, token, lastMessage, openChannel) {
}); });
socket.on("network:new", (data) => { socket.on("network:new", (data) => {
if (typeof data === "object") { if (_.isPlainObject(data)) {
// prevent people from overriding webirc settings // prevent people from overriding webirc settings
data.uuid = null; data.uuid = null;
data.commands = null; data.commands = null;
@ -404,7 +405,7 @@ function initializeClient(socket, client, token, lastMessage, openChannel) {
}); });
socket.on("network:edit", (data) => { socket.on("network:edit", (data) => {
if (typeof data !== "object") { if (!_.isPlainObject(data)) {
return; return;
} }
@ -418,14 +419,14 @@ function initializeClient(socket, client, token, lastMessage, openChannel) {
}); });
socket.on("history:clear", (data) => { socket.on("history:clear", (data) => {
if (typeof data === "object") { if (_.isPlainObject(data)) {
client.clearHistory(data); client.clearHistory(data);
} }
}); });
if (!Helper.config.public && !Helper.config.ldap.enable) { if (!Helper.config.public && !Helper.config.ldap.enable) {
socket.on("change-password", (data) => { socket.on("change-password", (data) => {
if (typeof data === "object") { if (_.isPlainObject(data)) {
const old = data.old_password; const old = data.old_password;
const p1 = data.new_password; const p1 = data.new_password;
const p2 = data.verify_password; const p2 = data.verify_password;
@ -475,13 +476,13 @@ function initializeClient(socket, client, token, lastMessage, openChannel) {
}); });
socket.on("sort", (data) => { socket.on("sort", (data) => {
if (typeof data === "object") { if (_.isPlainObject(data)) {
client.sort(data); client.sort(data);
} }
}); });
socket.on("names", (data) => { socket.on("names", (data) => {
if (typeof data === "object") { if (_.isPlainObject(data)) {
client.names(data); client.names(data);
} }
}); });
@ -496,7 +497,7 @@ function initializeClient(socket, client, token, lastMessage, openChannel) {
}); });
socket.on("msg:preview:toggle", (data) => { socket.on("msg:preview:toggle", (data) => {
if (typeof data !== "object") { if (!_.isPlainObject(data)) {
return; return;
} }
@ -546,7 +547,10 @@ function initializeClient(socket, client, token, lastMessage, openChannel) {
client.mentions.findIndex((m) => m.msgId === msgId), client.mentions.findIndex((m) => m.msgId === msgId),
1 1
); );
// TODO: emit to other clients? });
socket.on("mentions:hide_all", () => {
client.mentions = [];
}); });
if (!Helper.config.public) { if (!Helper.config.public) {
@ -594,7 +598,7 @@ function initializeClient(socket, client, token, lastMessage, openChannel) {
if (!Helper.config.public) { if (!Helper.config.public) {
socket.on("setting:set", (newSetting) => { socket.on("setting:set", (newSetting) => {
if (!newSetting || typeof newSetting !== "object") { if (!_.isPlainObject(newSetting)) {
return; return;
} }
@ -618,7 +622,7 @@ function initializeClient(socket, client, token, lastMessage, openChannel) {
client.save(); client.save();
if (newSetting.name === "highlights") { if (newSetting.name === "highlights" || newSetting.name === "highlightExceptions") {
client.compileCustomHighlights(); client.compileCustomHighlights();
} else if (newSetting.name === "awayMessage") { } else if (newSetting.name === "awayMessage") {
if (typeof newSetting.value !== "string") { if (typeof newSetting.value !== "string") {
@ -649,7 +653,7 @@ function initializeClient(socket, client, token, lastMessage, openChannel) {
socket.on("sign-out", (tokenToSignOut) => { socket.on("sign-out", (tokenToSignOut) => {
// If no token provided, sign same client out // If no token provided, sign same client out
if (!tokenToSignOut) { if (!tokenToSignOut || typeof tokenToSignOut !== "string") {
tokenToSignOut = token; tokenToSignOut = token;
} }
@ -666,7 +670,7 @@ function initializeClient(socket, client, token, lastMessage, openChannel) {
return; return;
} }
const socketToRemove = manager.sockets.of("/").connected[socketId]; const socketToRemove = manager.sockets.of("/").sockets.get(socketId);
socketToRemove.emit("sign-out"); socketToRemove.emit("sign-out");
socketToRemove.disconnect(); socketToRemove.disconnect();
@ -755,7 +759,7 @@ function getServerConfiguration() {
} }
function performAuthentication(data) { function performAuthentication(data) {
if (typeof data !== "object") { if (!_.isPlainObject(data)) {
return; return;
} }
@ -810,6 +814,10 @@ function performAuthentication(data) {
return; return;
} }
if (typeof data.user !== "string") {
return;
}
const authCallback = (success) => { const authCallback = (success) => {
// Authorization failed // Authorization failed
if (!success) { if (!success) {

View file

@ -1,7 +1,10 @@
"use strict"; "use strict";
const expect = require("chai").expect; const expect = require("chai").expect;
const findLinks = require("../../../../../client/js/helpers/ircmessageparser/findLinks"); const {
findLinks,
findLinksWithSchema,
} = require("../../../../../client/js/helpers/ircmessageparser/findLinks");
describe("findLinks", () => { describe("findLinks", () => {
it("should find url", () => { it("should find url", () => {
@ -354,4 +357,24 @@ describe("findLinks", () => {
expect(actual).to.deep.equal(expected); expect(actual).to.deep.equal(expected);
}); });
it("should not return urls with no schema if flag is specified", () => {
const input = "https://example.global //example.com http://example.group example.py";
const expected = [
{
link: "https://example.global",
start: 0,
end: 22,
},
{
end: 57,
link: "http://example.group",
start: 37,
},
];
const actual = findLinksWithSchema(input);
expect(actual).to.deep.equal(expected);
});
}); });

View file

@ -40,6 +40,7 @@ describe("Network", function () {
password: "", password: "",
username: "", username: "",
realname: "", realname: "",
leaveMessage: "",
sasl: "plain", sasl: "plain",
saslAccount: "testaccount", saslAccount: "testaccount",
saslPassword: "testpassword", saslPassword: "testpassword",
@ -109,10 +110,18 @@ describe("Network", function () {
it("editing a network should enforce correct types", function () { it("editing a network should enforce correct types", function () {
let saveCalled = false; let saveCalled = false;
let nameEmitCalled = false;
const network = new Network(); const network = new Network();
network.edit( network.edit(
{ {
emit(name, data) {
if (name === "network:name") {
nameEmitCalled = true;
expect(data.uuid).to.equal(network.uuid);
expect(data.name).to.equal("Lounge Test Network");
}
},
save() { save() {
saveCalled = true; saveCalled = true;
}, },
@ -133,12 +142,13 @@ describe("Network", function () {
commands: "/command 1 2 3\r\n/ping HELLO\r\r\r\r/whois test\r\n\r\n", commands: "/command 1 2 3\r\n/ping HELLO\r\r\r\r/whois test\r\n\r\n",
ip: "newIp", ip: "newIp",
hostname: "newHostname", hostname: "newHostname",
guid: "newGuid", uuid: "newuuid",
} }
); );
expect(saveCalled).to.be.true; expect(saveCalled).to.be.true;
expect(network.guid).to.not.equal("newGuid"); expect(nameEmitCalled).to.be.true;
expect(network.uuid).to.not.equal("newuuid");
expect(network.ip).to.be.undefined; expect(network.ip).to.be.undefined;
expect(network.hostname).to.be.undefined; expect(network.hostname).to.be.undefined;

View file

@ -49,7 +49,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
text: url, text: url,
}); });
link(this.irc, this.network.channels[0], message); link(this.irc, this.network.channels[0], message, message.text);
expect(message.previews).to.deep.equal([ expect(message.previews).to.deep.equal([
{ {
@ -86,7 +86,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
text: url, text: url,
}); });
link(this.irc, this.network.channels[0], message); link(this.irc, this.network.channels[0], message, message.text);
expect(message.previews).to.deep.equal([ expect(message.previews).to.deep.equal([
{ {
@ -122,7 +122,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
text: url, text: url,
}); });
link(this.irc, this.network.channels[0], message); link(this.irc, this.network.channels[0], message, message.text);
app.get("/truncate", function (req, res) { app.get("/truncate", function (req, res) {
res.send( res.send(
@ -146,7 +146,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
text: "http://localhost:" + this.port + "/basic-og", text: "http://localhost:" + this.port + "/basic-og",
}); });
link(this.irc, this.network.channels[0], message); link(this.irc, this.network.channels[0], message, message.text);
app.get("/basic-og", function (req, res) { app.get("/basic-og", function (req, res) {
res.send("<title>test</title><meta property='og:title' content='opengraph test'>"); res.send("<title>test</title><meta property='og:title' content='opengraph test'>");
@ -163,7 +163,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
text: "http://localhost:" + this.port + "/duplicate-tags", text: "http://localhost:" + this.port + "/duplicate-tags",
}); });
link(this.irc, this.network.channels[0], message); link(this.irc, this.network.channels[0], message, message.text);
app.get("/duplicate-tags", function (req, res) { app.get("/duplicate-tags", function (req, res) {
res.send( res.send(
@ -183,7 +183,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
text: "http://localhost:" + this.port + "/description-og", text: "http://localhost:" + this.port + "/description-og",
}); });
link(this.irc, this.network.channels[0], message); link(this.irc, this.network.channels[0], message, message.text);
app.get("/description-og", function (req, res) { app.get("/description-og", function (req, res) {
res.send( res.send(
@ -203,7 +203,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
text: "http://localhost:" + this.port + "/thumb", text: "http://localhost:" + this.port + "/thumb",
}); });
link(this.irc, this.network.channels[0], message); link(this.irc, this.network.channels[0], message, message.text);
app.get("/thumb", function (req, res) { app.get("/thumb", function (req, res) {
res.send( res.send(
@ -222,13 +222,77 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
}); });
}); });
describe("test disableMediaPreview", function () {
beforeEach(function (done) {
Helper.config.disableMediaPreview = true;
done();
});
afterEach(function (done) {
Helper.config.disableMediaPreview = false;
done();
});
it("should ignore og:image if disableMediaPreview", function (done) {
const port = this.port;
app.get("/nonexistent-test-image.png", function () {
throw "Should not fetch image";
});
app.get("/thumb", function (req, res) {
res.send(
"<title>Google</title><meta property='og:image' content='http://localhost:" +
port +
"/nonexistent-test-image.png'>"
);
});
const message = this.irc.createMessage({
text: "http://localhost:" + port + "/thumb",
});
link(this.irc, this.network.channels[0], message, message.text);
this.irc.once("msg:preview", function (data) {
expect(data.preview.head).to.equal("Google");
expect(data.preview.type).to.equal("link");
expect(data.preview.thumb).to.equal("");
done();
});
});
it("should ignore og:video if disableMediaPreview", function (done) {
const port = this.port;
app.get("/nonexistent-video.mpr", function () {
throw "Should not fetch video";
});
app.get("/thumb", function (req, res) {
res.send(
"<title>Google</title><meta property='og:video:type' content='video/mp4'><meta property='og:video' content='http://localhost:" +
port +
"/nonexistent-video.mp4'>"
);
});
const message = this.irc.createMessage({
text: "http://localhost:" + port + "/thumb",
});
link(this.irc, this.network.channels[0], message, message.text);
this.irc.once("msg:preview", function (data) {
expect(data.preview.head).to.equal("Google");
expect(data.preview.type).to.equal("link");
done();
});
});
});
it("should find image_src", function (done) { it("should find image_src", function (done) {
const port = this.port; const port = this.port;
const message = this.irc.createMessage({ const message = this.irc.createMessage({
text: "http://localhost:" + this.port + "/thumb-image-src", text: "http://localhost:" + this.port + "/thumb-image-src",
}); });
link(this.irc, this.network.channels[0], message); link(this.irc, this.network.channels[0], message, message.text);
app.get("/thumb-image-src", function (req, res) { app.get("/thumb-image-src", function (req, res) {
res.send( res.send(
@ -250,7 +314,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
text: "http://localhost:" + this.port + "/thumb-image-src", text: "http://localhost:" + this.port + "/thumb-image-src",
}); });
link(this.irc, this.network.channels[0], message); link(this.irc, this.network.channels[0], message, message.text);
app.get("/thumb-image-src", function (req, res) { app.get("/thumb-image-src", function (req, res) {
res.send("<link rel='image_src' href='//localhost:" + port + "/real-test-image.png'>"); res.send("<link rel='image_src' href='//localhost:" + port + "/real-test-image.png'>");
@ -270,7 +334,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
text: "http://localhost:" + this.port + "/relative-thumb", text: "http://localhost:" + this.port + "/relative-thumb",
}); });
link(this.irc, this.network.channels[0], message); link(this.irc, this.network.channels[0], message, message.text);
app.get("/relative-thumb", function (req, res) { app.get("/relative-thumb", function (req, res) {
res.send( res.send(
@ -294,7 +358,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
text: "http://localhost:" + this.port + "/thumb-no-title", text: "http://localhost:" + this.port + "/thumb-no-title",
}); });
link(this.irc, this.network.channels[0], message); link(this.irc, this.network.channels[0], message, message.text);
app.get("/thumb-no-title", function (req, res) { app.get("/thumb-no-title", function (req, res) {
res.send( res.send(
@ -320,7 +384,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
text: "http://localhost:" + this.port + "/body-no-title", text: "http://localhost:" + this.port + "/body-no-title",
}); });
link(this.irc, this.network.channels[0], message); link(this.irc, this.network.channels[0], message, message.text);
app.get("/body-no-title", function (req, res) { app.get("/body-no-title", function (req, res) {
res.send("<meta name='description' content='hello world'>"); res.send("<meta name='description' content='hello world'>");
@ -341,7 +405,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
text: "http://localhost:" + this.port + "/thumb-404", text: "http://localhost:" + this.port + "/thumb-404",
}); });
link(this.irc, this.network.channels[0], message); link(this.irc, this.network.channels[0], message, message.text);
app.get("/thumb-404", function (req, res) { app.get("/thumb-404", function (req, res) {
res.send( res.send(
@ -365,7 +429,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
text: "http://localhost:" + port + "/real-test-image.png", text: "http://localhost:" + port + "/real-test-image.png",
}); });
link(this.irc, this.network.channels[0], message); link(this.irc, this.network.channels[0], message, message.text);
this.irc.once("msg:preview", function (data) { this.irc.once("msg:preview", function (data) {
expect(data.preview.type).to.equal("image"); expect(data.preview.type).to.equal("image");
@ -384,7 +448,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
text: "http://localhost:" + port + "/one http://localhost:" + this.port + "/two", text: "http://localhost:" + port + "/one http://localhost:" + this.port + "/two",
}); });
link(this.irc, this.network.channels[0], message); link(this.irc, this.network.channels[0], message, message.text);
expect(message.previews).to.eql([ expect(message.previews).to.eql([
{ {
@ -447,7 +511,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
text: "http://localhost:" + this.port + "/language-check", text: "http://localhost:" + this.port + "/language-check",
}); });
link(this.irc, this.network.channels[0], message); link(this.irc, this.network.channels[0], message, message.text);
}); });
it("should send accept text/html for initial request", function (done) { it("should send accept text/html for initial request", function (done) {
@ -463,7 +527,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
text: "http://localhost:" + this.port + "/accept-header-html", text: "http://localhost:" + this.port + "/accept-header-html",
}); });
link(this.irc, this.network.channels[0], message); link(this.irc, this.network.channels[0], message, message.text);
}); });
it("should send accept */* for meta image", function (done) { it("should send accept */* for meta image", function (done) {
@ -487,7 +551,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
text: "http://localhost:" + port + "/accept-header-thumb", text: "http://localhost:" + port + "/accept-header-thumb",
}); });
link(this.irc, this.network.channels[0], message); link(this.irc, this.network.channels[0], message, message.text);
}); });
it("should not add slash to url", function (done) { it("should not add slash to url", function (done) {
@ -496,7 +560,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
text: "http://localhost:" + port + "", text: "http://localhost:" + port + "",
}); });
link(this.irc, this.network.channels[0], message); link(this.irc, this.network.channels[0], message, message.text);
this.irc.once("msg:preview", function (data) { this.irc.once("msg:preview", function (data) {
expect(data.preview.link).to.equal("http://localhost:" + port + ""); expect(data.preview.link).to.equal("http://localhost:" + port + "");
@ -527,7 +591,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
"/unicodeq/?q=🙈-emoji-test", "/unicodeq/?q=🙈-emoji-test",
}); });
link(this.irc, this.network.channels[0], message); link(this.irc, this.network.channels[0], message, message.text);
app.get("/unicode/:q", function (req, res) { app.get("/unicode/:q", function (req, res) {
res.send(`<title>${req.params.q}</title>`); res.send(`<title>${req.params.q}</title>`);
@ -559,19 +623,15 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
}); });
}); });
it("should fetch protocol-aware links", function (done) { it("should not fetch links without a schema", function () {
const port = this.port; const port = this.port;
const message = this.irc.createMessage({ const message = this.irc.createMessage({
text: "//localhost:" + port + "", text: `//localhost:${port} localhost:${port} //localhost:${port}/test localhost:${port}/test`,
}); });
link(this.irc, this.network.channels[0], message); link(this.irc, this.network.channels[0], message, message.text);
this.irc.once("msg:preview", function (data) { expect(message.previews).to.be.empty;
expect(data.preview.link).to.equal("http://localhost:" + port + "");
expect(data.preview.type).to.equal("error");
done();
});
}); });
it("should de-duplicate links", function (done) { it("should de-duplicate links", function (done) {
@ -587,7 +647,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
"", "",
}); });
link(this.irc, this.network.channels[0], message); link(this.irc, this.network.channels[0], message, message.text);
expect(message.previews).to.deep.equal([ expect(message.previews).to.deep.equal([
{ {
@ -635,9 +695,9 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
this.irc.config.browser.language = "very nice language"; this.irc.config.browser.language = "very nice language";
link(this.irc, this.network.channels[0], message); link(this.irc, this.network.channels[0], message, message.text);
link(this.irc, this.network.channels[0], message); link(this.irc, this.network.channels[0], message, message.text);
process.nextTick(() => link(this.irc, this.network.channels[0], message)); process.nextTick(() => link(this.irc, this.network.channels[0], message, message.text));
app.get("/basic-og-once", function (req, res) { app.get("/basic-og-once", function (req, res) {
requests++; requests++;
@ -674,11 +734,11 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
let responses = 0; let responses = 0;
this.irc.config.browser.language = "first language"; this.irc.config.browser.language = "first language";
link(this.irc, this.network.channels[0], message); link(this.irc, this.network.channels[0], message, message.text);
setTimeout(() => { setTimeout(() => {
this.irc.config.browser.language = "second language"; this.irc.config.browser.language = "second language";
link(this.irc, this.network.channels[0], message); link(this.irc, this.network.channels[0], message, message.text);
}, 100); }, 100);
app.get("/basic-og-once-lang", function (req, res) { app.get("/basic-og-once-lang", function (req, res) {

View file

@ -76,7 +76,7 @@ describe("Image storage", function () {
text: "http://localhost:" + port + "/thumb", text: "http://localhost:" + port + "/thumb",
}); });
link(this.irc, this.network.channels[0], message); link(this.irc, this.network.channels[0], message, message.text);
this.app.get("/thumb", function (req, res) { this.app.get("/thumb", function (req, res) {
res.send( res.send(
@ -100,7 +100,7 @@ describe("Image storage", function () {
text: "http://localhost:" + port + "/real-test-image.png", text: "http://localhost:" + port + "/real-test-image.png",
}); });
link(this.irc, this.network.channels[0], message); link(this.irc, this.network.channels[0], message, message.text);
this.irc.once("msg:preview", function (data) { this.irc.once("msg:preview", function (data) {
expect(data.preview.type).to.equal("image"); expect(data.preview.type).to.equal("image");
@ -124,7 +124,7 @@ describe("Image storage", function () {
); );
}); });
link(this.irc, this.network.channels[0], message); link(this.irc, this.network.channels[0], message, message.text);
this.irc.once("msg:preview", function (data) { this.irc.once("msg:preview", function (data) {
expect(data.preview.type).to.equal("link"); expect(data.preview.type).to.equal("link");

View file

@ -19,6 +19,10 @@ describe("public folder", function () {
expect(fs.existsSync(path.join(publicFolder, "thelounge.webmanifest"))).to.be.true; expect(fs.existsSync(path.join(publicFolder, "thelounge.webmanifest"))).to.be.true;
}); });
it("audio files are copied", function () {
expect(fs.existsSync(path.join(publicFolder, "audio", "pop.wav"))).to.be.true;
});
it("index HTML file is not copied", function () { it("index HTML file is not copied", function () {
expect(fs.existsSync(path.join(publicFolder, "index.html"))).to.be.false; expect(fs.existsSync(path.join(publicFolder, "index.html"))).to.be.false;
expect(fs.existsSync(path.join(publicFolder, "index.html.tpl"))).to.be.false; expect(fs.existsSync(path.join(publicFolder, "index.html.tpl"))).to.be.false;
@ -32,6 +36,8 @@ describe("public folder", function () {
it("style files are built", function () { it("style files are built", function () {
expect(fs.existsSync(path.join(publicFolder, "css", "style.css"))).to.be.true; expect(fs.existsSync(path.join(publicFolder, "css", "style.css"))).to.be.true;
expect(fs.existsSync(path.join(publicFolder, "css", "style.css.map"))).to.be.true; expect(fs.existsSync(path.join(publicFolder, "css", "style.css.map"))).to.be.true;
expect(fs.existsSync(path.join(publicFolder, "themes", "default.css"))).to.be.true;
expect(fs.existsSync(path.join(publicFolder, "themes", "morning.css"))).to.be.true;
}); });
it("style files contain expected content", function (done) { it("style files contain expected content", function (done) {
@ -55,4 +61,15 @@ describe("public folder", function () {
expect(fs.existsSync(path.join(publicFolder, "js", "loading-error-handlers.js"))).to.be expect(fs.existsSync(path.join(publicFolder, "js", "loading-error-handlers.js"))).to.be
.true; .true;
}); });
it("service worker has cacheName set", function (done) {
fs.readFile(path.join(publicFolder, "service-worker.js"), "utf8", function (err, contents) {
expect(err).to.be.null;
expect(contents.includes("const cacheName")).to.be.true;
expect(contents.includes("__HASH__")).to.be.false;
done();
});
});
}); });

View file

@ -22,7 +22,10 @@ describe("Custom highlights", function () {
}, },
"test", "test",
{ {
clientSettings: {highlights: "foo, @all, sp ace , 고"}, clientSettings: {
highlights: "foo, @all, sp ace , 고",
highlightExceptions: "foo bar, bar @all, test sp ace test",
},
} }
); );
@ -96,4 +99,53 @@ describe("Custom highlights", function () {
client.compileCustomHighlights(); client.compileCustomHighlights();
expect(client.highlightRegex).to.be.null; expect(client.highlightRegex).to.be.null;
}); });
// tests for highlight exceptions
it("should NOT highlight due to highlight exceptions", function () {
const teststrings = [
"foo bar baz",
"test foo bar",
"foo bar @all test",
"with a test sp ace test",
];
for (const teststring of teststrings) {
expect(teststring).to.match(client.highlightExceptionRegex);
}
});
it("should highlight regardless of highlight exceptions", function () {
const teststrings = [
"Hey foo hello",
"hey Foo: hi",
"hey Foo, hi",
"<foo> testing",
"foo",
"hey @all test",
"testing foo's stuff",
'"foo"',
'"@all"',
"foo!",
"www.foo.bar",
"www.bar.foo/page",
"고",
"test 고",
"고!",
"www.고.com",
"hey @Foo",
"hey ~Foo",
"hey +Foo",
"hello &foo",
"@all",
"@all wtf",
"wtfbar @all",
"@@all",
"@고",
"f00 sp ace: bar",
];
for (const teststring of teststrings) {
expect(teststring).to.not.match(client.highlightExceptionRegex);
}
});
}); });

View file

@ -41,7 +41,7 @@ const config = {
{ {
loader: MiniCssExtractPlugin.loader, loader: MiniCssExtractPlugin.loader,
options: { options: {
hmr: false, esModule: false,
}, },
}, },
{ {
@ -91,42 +91,50 @@ const config = {
new MiniCssExtractPlugin({ new MiniCssExtractPlugin({
filename: "css/style.css", filename: "css/style.css",
}), }),
new CopyPlugin([ new CopyPlugin({
{ patterns: [
from: "./node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.woff*", {
to: "fonts/[name].[ext]", from:
}, "./node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.woff*",
{ to: "fonts/[name].[ext]",
from: "./client/js/loading-error-handlers.js",
to: "js/[name].[ext]",
},
{
from: "./client/*",
to: "[name].[ext]",
ignore: ["index.html.tpl", "service-worker.js"],
},
{
from: "./client/service-worker.js",
to: "[name].[ext]",
transform(content) {
return content
.toString()
.replace("__HASH__", isProduction ? Helper.getVersionCacheBust() : "dev");
}, },
}, {
{ from: "./client/js/loading-error-handlers.js",
from: "./client/audio/*", to: "js/[name].[ext]",
to: "audio/[name].[ext]", },
}, {
{ from: "./client/*",
from: "./client/img/*", to: "[name].[ext]",
to: "img/[name].[ext]", globOptions: {
}, ignore: ["**/index.html.tpl", "**/service-worker.js"],
{ },
from: "./client/themes/*", },
to: "themes/[name].[ext]", {
}, from: "./client/service-worker.js",
]), to: "[name].[ext]",
transform(content) {
return content
.toString()
.replace(
"__HASH__",
isProduction ? Helper.getVersionCacheBust() : "dev"
);
},
},
{
from: "./client/audio/*",
to: "audio/[name].[ext]",
},
{
from: "./client/img/*",
to: "img/[name].[ext]",
},
{
from: "./client/themes/*",
to: "themes/[name].[ext]",
},
],
}),
// socket.io uses debug, we don't need it // socket.io uses debug, we don't need it
new webpack.NormalModuleReplacementPlugin( new webpack.NormalModuleReplacementPlugin(
/debug/, /debug/,

7497
yarn.lock

File diff suppressed because it is too large Load diff