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]
strict: off
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-use-v-if-with-v-for: off
vue/require-default-prop: off
vue/v-slot-style: [error, longform]
plugins:
- 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:
matrix:
os: [ubuntu-latest, windows-latest, macOS-latest]
node_version: [
10.x, # EOL: April 2021
12.x, # EOL: April 2022
]
exclude:
- os: macOS-latest
include:
# EOL: April 2021
- os: ubuntu-latest
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 }}
steps:
- uses: actions/checkout@master
- name: Setup Node.js
uses: actions/setup-node@v1
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node_version }}

View File

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

View File

@ -1,8 +1,5 @@
extends: stylelint-config-standard
ignoreFiles:
- client/css/bootstrap.css
rules:
indentation: tab
# 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 -->
## 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
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)
- 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,6 +6,7 @@
{self: message.self, highlight: message.highlight, 'previous-source': isPreviousSource},
]"
:data-type="message.type"
:data-command="message.command"
:data-from="message.from && message.from.nick"
>
<span :aria-label="messageTimeLocale" class="time tooltipped tooltipped-e"
@ -19,7 +20,7 @@
</template>
<template v-else-if="isAction()">
<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 v-else-if="message.type === 'action'">
<span class="from"><span class="only-copy">* </span></span>
@ -68,6 +69,12 @@
class="msg-shown-in-active tooltipped tooltipped-e"
><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" />
<LinkPreview
v-for="preview in message.previews"

View File

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

View File

@ -2,6 +2,12 @@
<span class="content">
<Username :user="message.from" />
<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
</span>
</template>

View File

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

View File

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

View File

@ -11,7 +11,9 @@
</template>
<template v-else>
Connect
<template v-if="config.lockNetwork">to {{ defaults.name }}</template>
<template v-if="config.lockNetwork && $store.state.serverConfiguration.public">
to {{ defaults.name }}
</template>
</template>
</h1>
<template v-if="!config.lockNetwork">
@ -97,6 +99,36 @@
</div>
</div>
</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>
<div class="connect-row">
@ -135,6 +167,16 @@
maxlength="300"
/>
</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">
<div class="connect-row">
<label for="connect:commands">
@ -270,9 +312,7 @@ the server tab on new connection"
</div>
</template>
<div v-else-if="defaults.sasl === 'external'" class="connect-sasl-external">
<p>
The Lounge automatically generates and manages the client certificate.
</p>
<p>The Lounge automatically generates and manages the client certificate.</p>
<p>
On the IRC server, you will need to tell the services to attach the
certificate fingerprint (certfp) to your account, for example:

View File

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

View File

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

View File

@ -6,11 +6,12 @@
v-on="onHover ? {mouseenter: hover} : {}"
@click.prevent="openContextMenu"
@contextmenu.prevent="openContextMenu"
><slot>{{ user.mode }}{{ user.nick }}</slot></span
><slot>{{ mode }}{{ user.nick }}</slot></span
>
</template>
<script>
import eventbus from "../js/eventbus";
import colorClass from "../js/helpers/colorClass";
export default {
@ -23,6 +24,14 @@ export default {
network: Object,
},
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() {
return colorClass(this.user.nick);
},
@ -32,7 +41,7 @@ export default {
return this.onHover(this.user);
},
openContextMenu(event) {
this.$root.$emit("contextmenu:user", {
eventbus.emit("contextmenu:user", {
event: event,
user: this.user,
network: this.network,

View File

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

View File

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

View File

@ -172,8 +172,14 @@
</div>
<div v-if="$store.state.settings.advanced">
<label class="opt">
<label for="nickPostfix" class="sr-only">
Nick autocomplete postfix (for example a comma)
<label for="nickPostfix" class="opt">
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>
<input
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'">
Loading
</template>
<template v-else>
Subscribe to push notifications
</template>
<template v-else> Subscribe to push notifications </template>
</button>
<div v-if="$store.state.pushNotificationState === 'nohttps'" class="error">
<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">
<label class="opt">
<label for="highlights" class="sr-only">
Custom highlights (comma-separated keywords)
<label for="highlights" class="opt">
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>
<input
id="highlights"
@ -360,7 +371,31 @@ This may break orientation if your browser does not support that."
type="text"
name="highlights"
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>
</div>
@ -376,9 +411,7 @@ This may break orientation if your browser does not support that."
>
<h2 id="label-change-password">Change password</h2>
<div class="password-container">
<label for="old_password_input" class="sr-only">
Enter current password
</label>
<label for="old_password_input" class="sr-only"> Enter current password </label>
<RevealPassword v-slot:default="slotProps">
<input
id="old_password_input"
@ -404,9 +437,7 @@ This may break orientation if your browser does not support that."
</RevealPassword>
</div>
<div class="password-container">
<label for="verify_password_input" class="sr-only">
Repeat new password
</label>
<label for="verify_password_input" class="sr-only"> Repeat new password </label>
<RevealPassword v-slot:default="slotProps">
<input
id="verify_password_input"

View File

@ -144,7 +144,7 @@ button {
code,
pre,
#chat .msg[data-type="motd"] .text,
#chat .msg[data-type="monospace_block"] .text,
.irc-monospace,
textarea#user-specified-css-input {
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="mode_channel"] .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_request"] .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="plugin"] .from::before,
#chat .msg[data-type="raw"] .from::before,
#chat .msg-statusmsg span::before,
#chat .msg-shown-in-active span::before,
#chat .toggle-button::after,
#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-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-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-op::before { content: "\f1fa"; /* http://fontawesome.io/icon/at/ */ }
.context-menu-action-voice::before { content: "\f067"; /* http://fontawesome.io/icon/plus/ */ }
.context-menu-action-set-mode::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-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 */ }
@ -428,11 +432,21 @@ p {
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 */
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_request"] .from::before {
content: "\f15c"; /* https://fontawesome.com/icons/file-alt?style=solid */
@ -479,16 +493,25 @@ p {
padding: 1px;
}
#chat .msg-statusmsg,
#chat .msg-shown-in-active {
cursor: help;
margin-right: 5px;
}
#chat .msg-statusmsg span::before,
#chat .msg-shown-in-active span::before {
font-size: 10px;
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 {
display: inline-block;
transition: opacity 0.2s, transform 0.2s;
@ -1448,11 +1471,11 @@ textarea.input {
width: 50px;
}
#chat.hide-motd .msg[data-type="motd"] {
#chat.hide-motd .msg[data-command="motd"] {
display: none !important;
}
#chat .msg[data-type="motd"] .text {
#chat .msg[data-type="monospace_block"] .text {
background: #f6f6f6;
display: inline-block;
border-radius: 4px;
@ -2020,6 +2043,14 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
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 {
display: flex;
align-items: center;
@ -2812,7 +2843,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
.header .topic,
#chat .msg[data-type="action"] .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 .ctcp-message,
#chat .part-reason,

View File

@ -1,5 +1,5 @@
<!doctype html>
<html>
<html lang="en">
<head>
<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";
import socket from "../socket";
import eventbus from "../eventbus";
export function generateChannelContextMenu($root, channel, network) {
const typeMap = {
@ -115,18 +116,31 @@ export function generateChannelContextMenu($root, channel, network) {
// Add menu items for queries
if (channel.type === "query") {
items.push({
label: "User information",
type: "item",
class: "action-whois",
action() {
$root.switchToChannel(channel);
socket.emit("input", {
target: channel.id,
text: "/whois " + channel.name,
});
items.push(
{
label: "User information",
type: "item",
class: "action-whois",
action() {
$root.switchToChannel(channel);
socket.emit("input", {
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") {
@ -135,7 +149,7 @@ export function generateChannelContextMenu($root, channel, network) {
type: "item",
class: "clear-history",
action() {
$root.$emit(
eventbus.emit(
"confirm-dialog",
{
title: "Clear history",
@ -203,6 +217,17 @@ export function generateUserContextMenu($root, channel, network, user) {
class: "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",
type: "item",
@ -222,66 +247,93 @@ export function generateUserContextMenu($root, channel, network, user) {
},
];
if (currentChannelUser.mode === "@") {
items.push({
label: "Kick",
type: "item",
class: "action-kick",
action() {
socket.emit("input", {
target: channel.id,
text: "/kick " + user.nick,
});
},
});
// Bail because we're in a query or we don't have a special mode.
if (!currentChannelUser.modes || currentChannelUser.modes.length < 1) {
return items;
}
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({
label: "Revoke operator (-o)",
label: modeTextTemplate.give(modes[prefix]),
type: "item",
class: "action-op",
class: "action-set-mode",
action() {
socket.emit("input", {
target: channel.id,
text: "/deop " + user.nick,
text: "/mode +" + modes[prefix][1] + " " + user.nick,
});
},
});
} else {
items.push({
label: "Give operator (+o)",
label: modeTextTemplate.revoke(modes[prefix]),
type: "item",
class: "action-op",
class: "action-revoke-mode",
action() {
socket.emit("input", {
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({
label: "Revoke voice (-v)",
label: "Kick",
type: "item",
class: "action-voice",
class: "action-kick",
action() {
socket.emit("input", {
target: channel.id,
text: "/devoice " + 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,
text: "/kick " + user.nick,
});
},
});

View File

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

View File

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

View File

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

View File

@ -33,12 +33,63 @@ const router = new VueRouter({
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) => {
// Disallow navigating to non-existing routes
if (store.state.appLoaded && !to.matched.length) {
if (!to.matched.length) {
next(false);
return;
}
@ -49,6 +100,12 @@ router.beforeEach((to, from, next) => {
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
if (!router.app.$refs.app) {
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 = {}) {
if (router.currentRoute.name) {
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: {
default: false,
sync: "never",
apply(store, value) {
store.commit("refreshDesktopNotificationState", null, {root: true});
@ -45,6 +46,10 @@ export const config = normalizeConfig({
default: "",
sync: "always",
},
highlightExceptions: {
default: "",
sync: "always",
},
awayMessage: {
default: "",
sync: "always",
@ -57,6 +62,7 @@ export const config = normalizeConfig({
},
notification: {
default: true,
sync: "never",
},
notifyAllMessages: {
default: false,

View File

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

View File

@ -1,8 +1,9 @@
"use strict";
import Vue from "vue";
import socket from "../socket";
import storage from "../localStorage";
import {router, switchToChannel, navigate, initialize as routerInitialize} from "../router";
import {router, switchToChannel, navigate} from "../router";
import store from "../store";
import parseIrcUri from "../helpers/parseIrcUri";
@ -16,10 +17,6 @@ socket.on("init", function (data) {
}
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");
socket.emit("setting:get");
@ -28,24 +25,27 @@ socket.on("init", function (data) {
window.g_TheLoungeRemoveLoading();
}
// TODO: Review this code and make it better
if (!router.currentRoute.name || router.currentRoute.name === "SignIn") {
const channel = store.getters.findChannel(data.active);
Vue.nextTick(() => {
// If we handled query parameters like irc:// links or just general
// 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) {
switchToChannel(channel.channel);
} else if (store.state.networks.length > 0) {
// 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
switchToChannel(store.state.networks[0].channels[0]);
} else {
navigate("Connect");
if (channel) {
switchToChannel(channel.channel);
} else if (store.state.networks.length > 0) {
// 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
switchToChannel(store.state.networks[0].channels[0]);
} else {
navigate("Connect");
}
}
}
}
if ("URLSearchParams" in window) {
handleQueryParams();
}
});
}
});
@ -154,6 +154,10 @@ function mergeChannelData(oldChannels, newChannels) {
}
function handleQueryParams() {
if (!("URLSearchParams" in window)) {
return false;
}
const params = new URLSearchParams(document.location.search);
const cleanParams = () => {
@ -169,11 +173,17 @@ function handleQueryParams() {
cleanParams();
router.push({name: "Connect", query: queryParams});
return true;
} else if (document.body.classList.contains("public") && document.location.search) {
// Set default connection settings from url params
const queryParams = Object.fromEntries(params.entries());
cleanParams();
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]);
}
});
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
if (typeof storedSettings.highlights === "object") {
if (storedSettings.highlights !== null && typeof storedSettings.highlights === "object") {
storedSettings.highlights = storedSettings.highlights.join(", ");
}

View File

@ -9,6 +9,7 @@ class Uploader {
init() {
this.xhr = null;
this.fileQueue = [];
this.tokenKeepAlive = null;
document.addEventListener("dragenter", (e) => this.dragEnter(e));
document.addEventListener("dragover", (e) => this.dragOver(e));
@ -131,10 +132,17 @@ class Uploader {
uploadNextFileInQueue(token) {
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 (
store.state.settings.uploadCanvas &&
file.type.startsWith("image/") &&
!file.type.includes("svg")
!file.type.includes("svg") &&
file.type !== "image/gif"
) {
this.renderImage(file, (newFile) => this.performUpload(token, newFile));
} else {
@ -219,6 +227,11 @@ class Uploader {
handleResponse(response) {
this.setProgress(0);
if (this.tokenKeepAlive) {
clearInterval(this.tokenKeepAlive);
this.tokenKeepAlive = null;
}
if (response.error) {
store.commit("currentUserVisibleError", response.error);
return;

View File

@ -9,6 +9,7 @@ import App from "../components/App.vue";
import storage from "./localStorage";
import {router, navigate} from "./router";
import socket from "./socket";
import eventbus from "./eventbus";
import "./socket-events";
import "./webpush";
@ -18,7 +19,7 @@ const favicon = document.getElementById("favicon");
const faviconNormal = favicon.getAttribute("href");
const faviconAlerted = favicon.dataset.other;
const vueApp = new Vue({
new Vue({
el: "#viewport",
router,
mounted() {
@ -30,7 +31,7 @@ const vueApp = new Vue({
},
closeChannel(channel) {
if (channel.type === "lobby") {
this.$root.$emit(
eventbus.emit(
"confirm-dialog",
{
title: "Remove network",
@ -75,7 +76,7 @@ store.watch(
(sidebarOpen) => {
if (window.innerWidth > constants.mobileViewportPixels) {
storage.set("thelounge.state.sidebar", sidebarOpen);
vueApp.$emit("resize");
eventbus.emit("resize");
}
}
);
@ -84,7 +85,7 @@ store.watch(
(state) => state.userlistOpen,
(userlistOpen) => {
storage.set("thelounge.state.userlist", userlistOpen);
vueApp.$emit("resize");
eventbus.emit("resize");
}
);
@ -100,6 +101,14 @@ store.watch(
(_, getters) => getters.highlightCount,
(highlightCount) => {
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) => {
socket.emit("push:register", subscription.toJSON());
store.commit("pushNotificationState", "subscribed");
store.commit("refreshDesktopNotificationState");
});
})
)
.catch((err) => {
store.commit("pushNotificationState", "unsupported");
store.commit("refreshDesktopNotificationState");
console.error(err); // eslint-disable-line no-console
});
}

View File

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

View File

@ -110,15 +110,27 @@ module.exports = {
// This value is set to `false` by default.
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`
// When set to `true`, The Lounge will store and proxy prefetched images and
// thumbnails on the filesystem rather than directly display the content at
// the original URLs.
//
// This improves security and privacy by not exposing the client IP address,
// always loading images from The Lounge and making all assets secure, which
// resolves mixed content warnings.
// This option primarily exists to resolve mixed content warnings by not
// loading images from http hosts. This option does not work for video
// 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
// in the `${THELOUNGE_HOME}/storage` folder.
@ -138,6 +150,15 @@ module.exports = {
// This value is set to `2048` kilobytes by default.
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`
//
// 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`.
// - `username`: User name.
// - `realname`: Real name.
// - `leaveMessage`: Network specific leave message (overrides global leaveMessage)
// - `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
@ -236,6 +258,7 @@ module.exports = {
username: "thelounge",
realname: "The Lounge User",
join: "#thelounge",
leaveMessage: "",
},
// ### `lockNetwork`

View File

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

View File

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

View File

@ -49,7 +49,7 @@ const _ = require("lodash");
const colors = require("chalk");
const fs = require("fs");
const path = require("path");
const GraphQLClient = require("graphql-request").GraphQLClient;
const got = require("got");
const dayjs = require("dayjs");
const semver = require("semver");
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.
// for a given repository of our organization.
class RepositoryFetcher {
// Holds a GraphQLClient and the name of the repository within the
// organization https://github.com/thelounge.
constructor(graphqlClient, repositoryName) {
this.graphqlClient = graphqlClient;
// Holds a Github token and repository name
constructor(githubToken, repositoryName) {
this.githubToken = githubToken;
this.repositoryName = repositoryName;
}
// Base function that actually makes the GraphQL API call
async fetch(query, variables = {}) {
return this.graphqlClient.request(
query,
Object.assign(variables, {repositoryName: this.repositoryName})
);
const response = await got
.post("https://api.github.com/graphql", {
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
@ -789,12 +801,6 @@ function extractContributors(entries) {
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
// entry and the list of contributors, for both pre-releases and stable
// releases. Templates are located at the top of this file.
@ -803,7 +809,7 @@ async function generateChangelogEntry(changelog, targetVersion) {
let template;
let contributors = [];
const codeRepo = new RepositoryFetcher(client, "thelounge");
const codeRepo = new RepositoryFetcher(token, "thelounge");
const previousVersion = await codeRepo.fetchPreviousVersion(targetVersion);
if (isPrerelease(targetVersion)) {
@ -817,7 +823,7 @@ async function generateChangelogEntry(changelog, targetVersion) {
items = parse(codeCommitsAndPullRequests);
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 websiteCommitsAndPullRequests = await websiteRepo.fetchCommitsAndPullRequestsSince(
"v" + previousWebsiteVersion

View File

@ -19,7 +19,14 @@ const emojiModifiersRegex = /[\u{1f3fb}-\u{1f3ff}]|\u{fe0f}/gu;
const cleanEmoji = emoji.emoji.replace(emojiModifiersRegex, "");
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;
}
}

View File

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

View File

@ -34,18 +34,40 @@ ClientManager.prototype.init = function (identHandler, sockets) {
};
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 () {
const users = this.getUsers();
let users = this.getUsers();
if (users.length === 0) {
log.info(
`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
const callbackLoadUser = (user) => {
this.loadUser(user);

View File

@ -2,6 +2,7 @@
const log = require("../log");
const colors = require("chalk");
const semver = require("semver");
const program = require("commander");
const Helper = require("../helper");
const Utils = require("./utils");
@ -40,6 +41,21 @@ program
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)}...`);
return Utils.executeYarnCommand("add", "--exact", `${json.name}@${json.version}`)

View File

@ -11,7 +11,9 @@ program
.command("add <name>")
.description("Add a new user")
.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())) {
log.error(`${Helper.getUsersPath()} does not exist.`);
return;
@ -31,6 +33,11 @@ program
return;
}
if (cmdObj.password) {
add(manager, name, cmdObj.password, !!cmdObj.saveLogs);
return;
}
log.prompt(
{
text: "Enter password:",

View File

@ -11,7 +11,8 @@ program
.command("reset <name>")
.description("Reset user password")
.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())) {
log.error(`${Helper.getUsersPath()} does not exist.`);
return;
@ -30,9 +31,10 @@ program
return;
}
const pathReal = Helper.getUserConfigPath(name);
const pathTemp = pathReal + ".tmp";
const user = JSON.parse(fs.readFileSync(pathReal, "utf-8"));
if (cmdObj.password) {
change(name, cmdObj.password);
return;
}
log.prompt(
{
@ -44,17 +46,25 @@ program
return;
}
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)}.`);
change(name, password);
}
);
});
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,
getVersion,
getVersionCacheBust,
getVersionNumber,
getGitCommit,
ip2hex,
mergeConfig,
@ -60,6 +61,10 @@ function getVersion() {
return gitCommit ? `source (${gitCommit} / ${version})` : version;
}
function getVersionNumber() {
return pkg.version;
}
let _gitCommit;
function getGitCommit() {

View File

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

View File

@ -35,6 +35,7 @@ function Network(attr) {
commands: [],
username: "",
realname: "",
leaveMessage: "",
sasl: "",
saslAccount: "",
saslPassword: "",
@ -82,6 +83,7 @@ Network.prototype.validate = function (client) {
this.username = cleanString(this.username) || "thelounge";
this.realname = cleanString(this.realname) || "The Lounge User";
this.leaveMessage = cleanString(this.leaveMessage);
this.password = cleanString(this.password);
this.host = cleanString(this.host).toLowerCase();
this.name = cleanString(this.name);
@ -120,7 +122,12 @@ Network.prototype.validate = function (client) {
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.port = Helper.config.defaults.port;
this.tls = Helper.config.defaults.tls;
@ -168,8 +175,10 @@ Network.prototype.createIrcFramework = function (client) {
enable_echomessage: true,
enable_setname: 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);
@ -197,8 +206,7 @@ Network.prototype.setIrcFrameworkOptions = function (client) {
this.irc.options.tls = this.tls;
this.irc.options.rejectUnauthorized = this.rejectUnauthorized;
this.irc.options.webirc = this.createWebIrc(client);
this.irc.options.client_certificate = this.tls ? ClientCertificate.get(this.uuid) : null;
this.irc.options.client_certificate = null;
if (!this.sasl) {
delete this.irc.options.sasl_mechanism;
@ -206,6 +214,7 @@ Network.prototype.setIrcFrameworkOptions = function (client) {
} else if (this.sasl === "external") {
this.irc.options.sasl_mechanism = "EXTERNAL";
this.irc.options.account = {};
this.irc.options.client_certificate = ClientCertificate.get(this.uuid);
} else if (this.sasl === "plain") {
delete this.irc.options.sasl_mechanism;
this.irc.options.account = {
@ -246,6 +255,7 @@ Network.prototype.createWebIrc = function (client) {
};
Network.prototype.edit = function (client, args) {
const oldNetworkName = this.name;
const oldNick = this.nick;
const oldRealname = this.realname;
@ -259,6 +269,7 @@ Network.prototype.edit = function (client, args) {
this.password = String(args.password || "");
this.username = String(args.username || "");
this.realname = String(args.realname || "");
this.leaveMessage = String(args.leaveMessage || "");
this.sasl = String(args.sasl || "");
this.saslAccount = String(args.saslAccount || "");
this.saslPassword = String(args.saslPassword || "");
@ -272,6 +283,14 @@ Network.prototype.edit = function (client, args) {
// Sync lobby channel 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)) {
return;
}
@ -420,7 +439,7 @@ Network.prototype.quit = function (quitMessage) {
// https://ircv3.net/specs/extensions/sts#rescheduling-expiry-on-disconnect
STSPolicies.refreshExpiration(this.host);
this.irc.quit(quitMessage || Helper.config.leaveMessage);
this.irc.quit(quitMessage || this.leaveMessage || Helper.config.leaveMessage);
};
Network.prototype.exportForEdit = function () {
@ -431,6 +450,7 @@ Network.prototype.exportForEdit = function () {
"password",
"username",
"realname",
"leaveMessage",
"sasl",
"saslAccount",
"saslPassword",
@ -465,6 +485,7 @@ Network.prototype.export = function () {
"password",
"username",
"realname",
"leaveMessage",
"sasl",
"saslAccount",
"saslPassword",

View File

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

View File

@ -13,19 +13,6 @@ module.exports = (app) => {
"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);
app.use(

View File

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

View File

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

View File

@ -8,7 +8,7 @@ const pkg = require("../../../package.json");
const ctcpResponses = {
CLIENTINFO: () =>
Object.getOwnPropertyNames(ctcpResponses) // TODO: This is currently handled by irc-framework
Object.getOwnPropertyNames(ctcpResponses)
.filter((key) => key !== "CLIENTINFO" && typeof ctcpResponses[key] === "function")
.join(" "),
PING: ({message}) => message.substring(5),
@ -67,17 +67,18 @@ module.exports = function (irc, network) {
return;
}
const target = data.from_server ? data.hostname : data.nick;
const response = ctcpResponses[data.type];
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
const msg = new Msg({
type: Msg.Type.CTCP_REQUEST,
time: data.time,
from: new User({nick: data.nick}),
from: new User({nick: target}),
hostmask: data.ident + "@" + data.hostname,
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,
from: user,
hostmask: data.ident + "@" + data.hostname,
gecos: data.gecos,
account: data.account,
type: Msg.Type.JOIN,
self: data.nick === irc.user.nick,
});

View File

@ -5,22 +5,18 @@ const got = require("got");
const URL = require("url").URL;
const mime = require("mime-types");
const Helper = require("../../helper");
const cleanIrcMessage = require("../../../client/js/helpers/ircmessageparser/cleanIrcMessage");
const findLinks = require("../../../client/js/helpers/ircmessageparser/findLinks");
const {findLinksWithSchema} = require("../../../client/js/helpers/ircmessageparser/findLinks");
const storage = require("../storage");
const currentFetchPromises = new Map();
const imageTypeRegex = /^image\/.+/;
const mediaTypeRegex = /^(audio|video)\/.+/;
module.exports = function (client, chan, msg) {
module.exports = function (client, chan, msg, cleanText) {
if (!Helper.config.prefetch) {
return;
}
// Remove all IRC formatting characters before searching for links
const cleanText = cleanIrcMessage(msg.text);
msg.previews = findLinks(cleanText).reduce((cleanLinks, link) => {
msg.previews = findLinksWithSchema(cleanText).reduce((cleanLinks, link) => {
const url = normalizeURL(link.link);
// 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[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) {
preview.head = preview.head.substr(0, 100);
@ -98,6 +89,17 @@ function parseHtml(preview, res, client) {
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
if (thumb.length) {
thumb = normalizeURL(thumb, preview.link) || "";
@ -127,7 +129,25 @@ function parseHtml(preview, res, client) {
function parseHtmlMedia($, preview, client) {
return new Promise((resolve, reject) => {
if (Helper.config.disableMediaPreview) {
reject();
return;
}
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) => {
if (foundMedia) {
@ -203,6 +223,11 @@ function parse(msg, chan, preview, res, client) {
case "image/jpg":
case "image/jpeg":
case "image/webp":
case "image/avif":
if (!Helper.config.prefetchStorage && Helper.config.disableMediaPreview) {
return removePreview(msg, preview);
}
if (res.size > Helper.config.prefetchMaxImageSize * 1024) {
preview.type = "error";
preview.error = "image-too-big";
@ -228,6 +253,10 @@ function parse(msg, chan, preview, res, client) {
break;
}
if (Helper.config.disableMediaPreview) {
return removePreview(msg, preview);
}
preview.type = "audio";
preview.media = preview.link;
preview.mediaType = res.type;
@ -241,6 +270,10 @@ function parse(msg, chan, preview, res, client) {
break;
}
if (Helper.config.disableMediaPreview) {
return removePreview(msg, preview);
}
preview.type = "video";
preview.media = preview.link;
preview.mediaType = res.type;
@ -354,7 +387,9 @@ function fetch(uri, headers) {
retry: 0,
timeout: 5000,
headers: getRequestHeaders(headers),
rejectUnauthorized: false,
https: {
rejectUnauthorized: false,
},
});
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
gotStream.destroy();
} else {
// if not image, limit download to 50kb, since we need only meta tags
// twitter.com sends opengraph meta tags within ~20kb of data for individual tweets
limit = 1024 * 50;
// 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, the default is set to 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))

View File

@ -115,6 +115,9 @@ module.exports = function (irc, network) {
msg.showInActive = true;
}
// remove IRC formatting for custom highlight testing
const cleanMessage = cleanIrcMessage(data.message);
// Self messages in channels are never highlighted
// Non-self messages are highlighted as soon as the nick is detected
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 (!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;
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
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);
@ -144,7 +156,7 @@ module.exports = function (irc, network) {
// Do not send notifications for messages older than 15 minutes (znc buffer for example)
if (msg.highlight && (!data.time || data.time > Date.now() - 900000)) {
let title = chan.name;
let body = cleanIrcMessage(data.message);
let body = cleanMessage;
if (msg.type === Msg.Type.ACTION) {
// 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,
});
for (const param of data.raw_params) {
const users = [];
const users = [];
for (const param of data.raw_params) {
if (targetChan.findUser(param)) {
users.push(param);
}
}
if (users.length > 0) {
msg.users = users;
}
if (users.length > 0) {
msg.users = users;
}
targetChan.pushMessage(client, msg);
@ -117,9 +117,6 @@ module.exports = function (irc, network) {
return userModeSortPriority[a] - userModeSortPriority[b];
});
}
// TODO: remove in future
user.mode = (user.modes && user.modes[0]) || "";
});
if (!usersUpdated) {

View File

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

View File

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

View File

@ -4,6 +4,7 @@ const _ = require("lodash");
const log = require("../../log");
const colors = require("chalk");
const path = require("path");
const semver = require("semver");
const Helper = require("../../helper");
const themes = require("./themes");
const packageMap = new Map();
@ -93,6 +94,13 @@ function loadPackage(packageName) {
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);
} catch (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 isUtf8 = require("is-utf8");
const log = require("../log");
const contentDisposition = require("content-disposition");
const whitelist = [
"application/ogg",
"audio/midi",
"audio/mpeg",
"audio/ogg",
"audio/vnd.wave",
"image/bmp",
"image/gif",
"image/jpeg",
"image/png",
"image/webp",
"text/plain",
"video/mp4",
"video/ogg",
"video/webm",
];
// Map of allowed mime types to their respecive default filenames
// that will be rendered in browser without forcing them to be downloaded
const inlineContentDispositionTypes = {
"application/ogg": "media.ogx",
"audio/midi": "audio.midi",
"audio/mpeg": "audio.mp3",
"audio/ogg": "audio.ogg",
"audio/vnd.wave": "audio.wav",
"audio/flac": "audio.flac",
"image/bmp": "image.bmp",
"image/gif": "image.gif",
"image/jpeg": "image.jpg",
"image/png": "image.png",
"image/webp": "image.webp",
"image/avif": "image.avif",
"text/plain": "text.txt",
"video/mp4": "video.mp4",
"video/ogg": "video.ogv",
"video/webm": "video.webm",
};
const uploadTokens = new Map();
@ -35,17 +40,33 @@ class Uploader {
socket.on("upload:auth", () => {
const token = uuidv4();
uploadTokens.set(token, true);
socket.emit("upload:auth", token);
// 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) {
return whitelist.includes(mimeType);
static createTokenTimeout(token) {
return setTimeout(() => uploadTokens.delete(token), 60 * 1000);
}
static router(express) {
@ -72,8 +93,21 @@ class Uploader {
return res.status(404).send("Not found");
}
// Force a download in the browser if it's not a whitelisted type (binary or otherwise unknown)
const contentDisposition = Uploader.isValidType(detectedMimeType) ? "inline" : "attachment";
// Force a download in the browser if it's not an allowed type (binary or otherwise unknown)
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") {
// Send a more common mime type for wave audio files
@ -81,7 +115,7 @@ class Uploader {
detectedMimeType = "audio/wav";
}
res.setHeader("Content-Disposition", contentDisposition);
res.setHeader("Content-Disposition", disposition);
res.setHeader("Cache-Control", "max-age=86400");
res.contentType(detectedMimeType);

View File

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

View File

@ -1,7 +1,10 @@
"use strict";
const expect = require("chai").expect;
const findLinks = require("../../../../../client/js/helpers/ircmessageparser/findLinks");
const {
findLinks,
findLinksWithSchema,
} = require("../../../../../client/js/helpers/ircmessageparser/findLinks");
describe("findLinks", () => {
it("should find url", () => {
@ -354,4 +357,24 @@ describe("findLinks", () => {
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: "",
username: "",
realname: "",
leaveMessage: "",
sasl: "plain",
saslAccount: "testaccount",
saslPassword: "testpassword",
@ -109,10 +110,18 @@ describe("Network", function () {
it("editing a network should enforce correct types", function () {
let saveCalled = false;
let nameEmitCalled = false;
const network = new Network();
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() {
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",
ip: "newIp",
hostname: "newHostname",
guid: "newGuid",
uuid: "newuuid",
}
);
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.hostname).to.be.undefined;

View File

@ -49,7 +49,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
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([
{
@ -86,7 +86,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
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([
{
@ -122,7 +122,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
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) {
res.send(
@ -146,7 +146,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
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) {
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",
});
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) {
res.send(
@ -183,7 +183,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
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) {
res.send(
@ -203,7 +203,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
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) {
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) {
const port = this.port;
const message = this.irc.createMessage({
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) {
res.send(
@ -250,7 +314,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
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) {
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",
});
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) {
res.send(
@ -294,7 +358,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
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) {
res.send(
@ -320,7 +384,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
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) {
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",
});
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) {
res.send(
@ -365,7 +429,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
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) {
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",
});
link(this.irc, this.network.channels[0], message);
link(this.irc, this.network.channels[0], message, message.text);
expect(message.previews).to.eql([
{
@ -447,7 +511,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
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) {
@ -463,7 +527,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
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) {
@ -487,7 +551,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
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) {
@ -496,7 +560,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
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) {
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",
});
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) {
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 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(data.preview.link).to.equal("http://localhost:" + port + "");
expect(data.preview.type).to.equal("error");
done();
});
expect(message.previews).to.be.empty;
});
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([
{
@ -635,9 +695,9 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
this.irc.config.browser.language = "very nice language";
link(this.irc, this.network.channels[0], message);
link(this.irc, this.network.channels[0], message);
process.nextTick(() => 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, message.text);
process.nextTick(() => link(this.irc, this.network.channels[0], message, message.text));
app.get("/basic-og-once", function (req, res) {
requests++;
@ -674,11 +734,11 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
let responses = 0;
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(() => {
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);
app.get("/basic-og-once-lang", function (req, res) {

View File

@ -76,7 +76,7 @@ describe("Image storage", function () {
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) {
res.send(
@ -100,7 +100,7 @@ describe("Image storage", function () {
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) {
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) {
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;
});
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 () {
expect(fs.existsSync(path.join(publicFolder, "index.html"))).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 () {
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, "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) {
@ -55,4 +61,15 @@ describe("public folder", function () {
expect(fs.existsSync(path.join(publicFolder, "js", "loading-error-handlers.js"))).to.be
.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",
{
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();
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,
options: {
hmr: false,
esModule: false,
},
},
{
@ -91,42 +91,50 @@ const config = {
new MiniCssExtractPlugin({
filename: "css/style.css",
}),
new CopyPlugin([
{
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");
new CopyPlugin({
patterns: [
{
from:
"./node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.woff*",
to: "fonts/[name].[ext]",
},
},
{
from: "./client/audio/*",
to: "audio/[name].[ext]",
},
{
from: "./client/img/*",
to: "img/[name].[ext]",
},
{
from: "./client/themes/*",
to: "themes/[name].[ext]",
},
]),
{
from: "./client/js/loading-error-handlers.js",
to: "js/[name].[ext]",
},
{
from: "./client/*",
to: "[name].[ext]",
globOptions: {
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/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
new webpack.NormalModuleReplacementPlugin(
/debug/,

7497
yarn.lock

File diff suppressed because it is too large Load Diff