Merge with master

This commit is contained in:
Max Leiter 2021-07-18 23:35:00 -07:00
commit 2721395ada
No known key found for this signature in database
GPG key ID: C6237BF113E61F28
54 changed files with 1991 additions and 1803 deletions

View file

@ -2,7 +2,7 @@
root: true
parserOptions:
ecmaVersion: 2018
ecmaVersion: 2020
env:
es6: true

View file

@ -4,7 +4,7 @@ about: Create a bug report
labels: "Type: Bug"
---
<!-- Have a question? Join #thelounge on freenode -->
<!-- Have a question? Join #thelounge on Libera.Chat -->
- _Node version:_
- _Browser version:_

View file

@ -4,7 +4,7 @@ about: Request a new feature
labels: "Type: Feature"
---
<!-- Have a question? Join #thelounge on freenode. -->
<!-- Have a question? Join #thelounge on Libera.Chat. -->
<!-- Make sure to check the existing issues prior to submitting your suggestion. -->
### Feature Description

View file

@ -13,4 +13,4 @@ contact_links:
- name: General support
url: https://demo.thelounge.chat/?join=%23thelounge
about: "Join #thelounge on Freenode to ask a question before creating an issue"
about: "Join #thelounge on Libera.Chat to ask a question before creating an issue"

2
.github/SUPPORT.md vendored
View file

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

View file

@ -4,6 +4,51 @@ All notable changes to this project will be documented in this file.
<!-- New entries go after this line -->
## v4.3.0-pre.4 - 2021-07-01 [Pre-release]
[See the full changelog](https://github.com/thelounge/thelounge/compare/v4.3.0-pre.3...v4.3.0-pre.4)
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.3.0-pre.3 - 2021-06-29 [Pre-release]
[See the full changelog](https://github.com/thelounge/thelounge/compare/v4.3.0-pre.2...v4.3.0-pre.3)
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.3.0-pre.2 - 2021-06-07 [Pre-release]
[See the full changelog](https://github.com/thelounge/thelounge/compare/v4.3.0-pre.1...v4.3.0-pre.2)
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.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)

View file

@ -20,8 +20,8 @@
</p>
<p align="center">
<a href="https://demo.thelounge.chat/"><img
alt="#thelounge IRC channel on freenode"
src="https://img.shields.io/badge/freenode-%23thelounge-415364.svg?colorA=ff9e18"></a>
alt="#thelounge IRC channel on Libera.Chat"
src="https://img.shields.io/badge/Libera.Chat-%23thelounge-415364.svg?colorA=ff9e18"></a>
<a href="https://yarn.pm/thelounge"><img
alt="npm version"
src="https://img.shields.io/npm/v/thelounge.svg?colorA=333a41&maxAge=3600"></a>

View file

@ -4,6 +4,6 @@
- Contact us privately first, in a
[responsible disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure)
manner.
- On IRC, send a private message to any voiced user on our Freenode channel,
- On IRC, send a private message to any voiced user on our Libera.Chat channel,
`#thelounge`.
- By email, send us your report at <security@thelounge.chat>.

View file

@ -1,7 +1,12 @@
<template>
<div id="viewport" :class="viewportClasses" role="tablist">
<Sidebar v-if="$store.state.appLoaded" :overlay="$refs.overlay" />
<div id="sidebar-overlay" ref="overlay" @click="$store.commit('sidebarOpen', false)" />
<div
id="sidebar-overlay"
ref="overlay"
aria-hidden="true"
@click="$store.commit('sidebarOpen', false)"
/>
<router-view ref="window"></router-view>
<Mentions />
<ImageViewer ref="imageViewer" />
@ -51,6 +56,7 @@ export default {
Mousetrap.bind("esc", this.escapeKey);
Mousetrap.bind("alt+u", this.toggleUserList);
Mousetrap.bind("alt+s", this.toggleSidebar);
Mousetrap.bind("alt+m", this.toggleMentions);
// Make a single throttled resize listener available to all components
this.debouncedResize = throttle(() => {
@ -72,6 +78,7 @@ export default {
Mousetrap.unbind("esc", this.escapeKey);
Mousetrap.unbind("alt+u", this.toggleUserList);
Mousetrap.unbind("alt+s", this.toggleSidebar);
Mousetrap.unbind("alt+m", this.toggleMentions);
window.removeEventListener("resize", this.debouncedResize);
clearTimeout(this.dayChangeTimeout);
@ -98,6 +105,11 @@ export default {
return false;
},
toggleMentions() {
if (this.$store.state.networks.length !== 0) {
eventbus.emit("mentions:toggle");
}
},
msUntilNextDay() {
// Compute how many milliseconds are remaining until the next day starts
const today = new Date();

View file

@ -54,20 +54,25 @@ export default {
methods: {
getAriaLabel() {
const extra = [];
const type = this.channel.type;
if (this.channel.unread > 0) {
extra.push(`${this.channel.unread} unread`);
if (this.channel.unread > 1) {
extra.push(`${this.channel.unread} unread messages`);
} else {
extra.push(`${this.channel.unread} unread message`);
}
}
if (this.channel.highlight > 0) {
extra.push(`${this.channel.highlight} mention`);
if (this.channel.highlight > 1) {
extra.push(`${this.channel.highlight} mentions`);
} else {
extra.push(`${this.channel.highlight} mention`);
}
}
if (extra.length > 0) {
return `${this.channel.name} (${extra.join(", ")})`;
}
return this.channel.name;
return `${type}: ${this.channel.name} ${extra.length ? `(${extra.join(", ")})` : ""}`;
},
click() {
if (this.isFiltering) {

View file

@ -18,7 +18,9 @@
>
<div class="header">
<SidebarToggle />
<span class="title">{{ channel.name }}</span>
<span class="title" :aria-label="'Currently open ' + channel.type">{{
channel.name
}}</span>
<div v-if="channel.editTopic === true" class="topic-container">
<input
ref="topicInput"
@ -93,13 +95,13 @@
>
<div class="scroll-down-arrow" />
</div>
<ChatUserList v-if="channel.type === 'channel'" :channel="channel" />
<MessageList
ref="messageList"
:network="network"
:channel="channel"
:focused="focused"
/>
<ChatUserList v-if="channel.type === 'channel'" :channel="channel" />
</div>
</div>
</div>

View file

@ -140,22 +140,53 @@ export default {
return;
}
const oldValue = this.$refs.input.value;
const oldPosition = this.$refs.input.selectionStart;
const onRow = (oldValue.slice(null, oldPosition).match(/\n/g) || []).length;
const totalRows = (oldValue.match(/\n/g) || []).length;
const {channel} = this;
if (channel.inputHistoryPosition === 0) {
channel.inputHistory[channel.inputHistoryPosition] = channel.pendingMessage;
}
if (key === "up") {
if (key === "up" && onRow === 0) {
if (channel.inputHistoryPosition < channel.inputHistory.length - 1) {
channel.inputHistoryPosition++;
} else {
return;
}
} else if (channel.inputHistoryPosition > 0) {
} else if (key === "down" && channel.inputHistoryPosition > 0 && onRow === totalRows) {
channel.inputHistoryPosition--;
} else {
return;
}
channel.pendingMessage = channel.inputHistory[channel.inputHistoryPosition];
this.$refs.input.value = channel.pendingMessage;
const newValue = channel.pendingMessage;
this.$refs.input.value = newValue;
let newPosition;
if (key === "up") {
const lastIndexOfNewLine = newValue.lastIndexOf("\n");
const lastLine = newValue.slice(null, lastIndexOfNewLine);
newPosition =
oldPosition > lastLine.length
? newValue.length
: lastIndexOfNewLine + oldPosition + 1;
} else {
const lastPositionOnFirstLine =
newValue.indexOf("\n") === -1 ? newValue.length + 1 : newValue.indexOf("\n");
const relativeRowPos = oldPosition - oldValue.lastIndexOf("\n") - 1;
newPosition =
relativeRowPos > lastPositionOnFirstLine
? lastPositionOnFirstLine
: relativeRowPos;
}
this.$refs.input.setSelectionRange(newPosition, newPosition);
this.setInputSize();
return false;
@ -183,6 +214,10 @@ export default {
},
setInputSize() {
this.$nextTick(() => {
if (!this.$refs.input) {
return;
}
const style = window.getComputedStyle(this.$refs.input);
const lineHeight = parseFloat(style.lineHeight, 10) || 1;

View file

@ -1,5 +1,10 @@
<template>
<aside ref="userlist" class="userlist" @mouseleave="removeHoverUser">
<aside
ref="userlist"
class="userlist"
:aria-label="'User list for ' + channel.name"
@mouseleave="removeHoverUser"
>
<div class="count">
<input
ref="input"

View file

@ -13,8 +13,12 @@
:data-command="message.command"
:data-from="message.from && message.from.nick"
>
<span :aria-label="messageTimeLocale" class="time tooltipped tooltipped-e"
>{{ messageTime }}
<span
aria-hidden="true"
:aria-label="messageTimeLocale"
class="time tooltipped tooltipped-e"
>
{{ messageTime }}
</span>
<template v-if="message.type === 'unhandled'">
<span class="from">[{{ message.command }}]</span>
@ -47,23 +51,23 @@
<template v-else>
<span v-if="message.type === 'message'" class="from">
<template v-if="message.from && message.from.nick">
<span class="only-copy">&lt;</span>
<span class="only-copy" aria-hidden="true">&lt;</span>
<Username :user="message.from" :network="network" :channel="channel" />
<span class="only-copy">&gt; </span>
<span class="only-copy" aria-hidden="true">&gt; </span>
</template>
</span>
<span v-else-if="message.type === 'plugin'" class="from">
<template v-if="message.from && message.from.nick">
<span class="only-copy">[</span>
<span class="only-copy" aria-hidden="true">[</span>
{{ message.from.nick }}
<span class="only-copy">] </span>
<span class="only-copy" aria-hidden="true">] </span>
</template>
</span>
<span v-else class="from">
<template v-if="message.from && message.from.nick">
<span class="only-copy">-</span>
<span class="only-copy" aria-hidden="true">-</span>
<Username :user="message.from" :network="network" :channel="channel" />
<span class="only-copy">- </span>
<span class="only-copy" aria-hidden="true">- </span>
</template>
</span>
<span class="content" dir="auto">

View file

@ -4,7 +4,7 @@
<input
ref="searchInputField"
v-model="searchInput"
type="text"
type="search"
name="search"
class="input"
placeholder="Search messages…"
@ -104,6 +104,10 @@ export default {
mounted() {
this.searchInput = this.$route.query.q;
this.searchOpened = this.onSearchPage;
if (!this.searchInput && this.searchOpened) {
this.$refs.searchInputField.focus();
}
},
methods: {
closeSearch() {

View file

@ -6,17 +6,20 @@
>username to <b>{{ message.new_ident }}</b></span
>
<span v-if="message.new_host"
>hostname to <i class="hostmask">{{ message.new_host }}</i></span
>
>hostname to
<i class="hostmask"><ParsedMessage :network="network" :text="message.new_host" /></i
></span>
</span>
</template>
<script>
import ParsedMessage from "../ParsedMessage.vue";
import Username from "../Username.vue";
export default {
name: "MessageTypeChangeHost",
components: {
ParsedMessage,
Username,
},
props: {

View file

@ -1,7 +1,7 @@
<template>
<span class="content">
<Username :user="message.from" />
<i class="hostmask"> ({{ message.hostmask }})</i>
<i class="hostmask"> (<ParsedMessage :network="network" :text="message.hostmask" />)</i>
<template v-if="message.account">
<i class="account"> [{{ message.account }}]</i>
</template>
@ -13,11 +13,13 @@
</template>
<script>
import ParsedMessage from "../ParsedMessage.vue";
import Username from "../Username.vue";
export default {
name: "MessageTypeJoin",
components: {
ParsedMessage,
Username,
},
props: {

View file

@ -1,7 +1,8 @@
<template>
<span class="content">
<Username :user="message.from" />
<i class="hostmask"> ({{ message.hostmask }})</i> has left the channel
<i class="hostmask"> (<ParsedMessage :network="network" :text="message.hostmask" />)</i> has
left the channel
<i v-if="message.text" class="part-reason"
>(<ParsedMessage :network="network" :message="message" />)</i
>

View file

@ -1,7 +1,8 @@
<template>
<span class="content">
<Username :user="message.from" />
<i class="hostmask"> ({{ message.hostmask }})</i> has quit
<i class="hostmask"> (<ParsedMessage :network="network" :text="message.hostmask" />)</i> has
quit
<i v-if="message.text" class="quit-reason"
>(<ParsedMessage :network="network" :message="message" />)</i
>

View file

@ -12,7 +12,12 @@
</template>
<dt>Host mask:</dt>
<dd class="hostmask">{{ message.whois.ident }}@{{ message.whois.hostname }}</dd>
<dd class="hostmask">
<ParsedMessage
:network="network"
:text="message.whois.ident + '@' + message.whois.hostname"
/>
</dd>
<template v-if="message.whois.actual_hostname">
<dt>Actual host:</dt>

View file

@ -98,6 +98,80 @@
</label>
</div>
</div>
<h2>Proxy Settings</h2>
<div class="connect-row">
<label></label>
<div class="input-wrap">
<label for="connect:proxyEnabled">
<input
id="connect:proxyEnabled"
v-model="defaults.proxyEnabled"
type="checkbox"
name="proxyEnabled"
/>
Enable Proxy
</label>
</div>
</div>
<template v-if="defaults.proxyEnabled">
<div class="connect-row">
<label for="connect:proxyHost">SOCKS Address</label>
<div class="input-wrap">
<input
id="connect:proxyHost"
v-model="defaults.proxyHost"
class="input"
name="proxyHost"
aria-label="Proxy host"
maxlength="255"
/>
<span id="connect:proxyPortSeparator">:</span>
<input
id="connect:proxyPort"
v-model="defaults.proxyPort"
class="input"
type="number"
min="1"
max="65535"
name="proxyPort"
aria-label="SOCKS port"
/>
</div>
</div>
<div class="connect-row">
<label for="connect:proxyUsername">Proxy username</label>
<input
id="connect:proxyUsername"
ref="proxyUsernameInput"
v-model="defaults.proxyUsername"
class="input username"
name="proxyUsername"
maxlength="100"
placeholder="Proxy username"
/>
</div>
<div class="connect-row">
<label for="connect:proxyPassword">Proxy password</label>
<RevealPassword
v-slot:default="slotProps"
class="input-wrap password-container"
>
<input
id="connect:proxyPassword"
ref="proxyPassword"
v-model="defaults.proxyPassword"
class="input"
:type="slotProps.isVisible ? 'text' : 'password'"
placeholder="Proxy password"
name="password"
maxlength="300"
/>
</RevealPassword>
</div>
</template>
</template>
<template v-else-if="config.lockNetwork && !$store.state.serverConfiguration.public">
<h2>Network settings</h2>

View file

@ -1,8 +1,13 @@
<template>
<div v-if="$store.state.networks.length === 0" class="empty">
<div
v-if="$store.state.networks.length === 0"
class="empty"
role="navigation"
aria-label="Network and Channel list"
>
You are not connected to any networks yet.
</div>
<div v-else ref="networklist">
<div v-else ref="networklist" role="navigation" aria-label="Network and Channel list">
<div class="jump-to-input">
<input
ref="searchInput"
@ -74,6 +79,7 @@
}"
class="network"
role="region"
aria-live="polite"
>
<NetworkLobby
:network="network"

View file

@ -6,11 +6,13 @@
:src="`img/logo-${isPublic() ? 'horizontal-' : ''}transparent-bg.svg`"
class="logo"
alt="The Lounge"
role="presentation"
/>
<img
:src="`img/logo-${isPublic() ? 'horizontal-' : ''}transparent-bg-inverted.svg`"
class="logo-inverted"
alt="The Lounge"
role="presentation"
/>
<span
v-if="isDevelopment"

View file

@ -9,7 +9,7 @@
</thead>
<tbody>
<tr v-for="ban in channel.data" :key="ban.hostmask">
<td class="hostmask">{{ ban.hostmask }}</td>
<td class="hostmask"><ParsedMessage :network="network" :text="ban.hostmask" /></td>
<td class="banned_by">{{ ban.banned_by }}</td>
<td class="banned_at">{{ localetime(ban.banned_at) }}</td>
</tr>
@ -18,10 +18,14 @@
</template>
<script>
import ParsedMessage from "../ParsedMessage.vue";
import localetime from "../../js/helpers/localetime";
export default {
name: "ListBans",
components: {
ParsedMessage,
},
props: {
network: Object,
channel: Object,

View file

@ -8,7 +8,7 @@
</thead>
<tbody>
<tr v-for="user in channel.data" :key="user.hostmask">
<td class="hostmask">{{ user.hostmask }}</td>
<td class="hostmask"><ParsedMessage :network="network" :text="user.hostmask" /></td>
<td class="when">{{ localetime(user.when) }}</td>
</tr>
</tbody>
@ -16,10 +16,14 @@
</template>
<script>
import ParsedMessage from "../ParsedMessage.vue";
import localetime from "../../js/helpers/localetime";
export default {
name: "ListIgnored",
components: {
ParsedMessage,
},
props: {
network: Object,
channel: Object,

View file

@ -9,7 +9,9 @@
</thead>
<tbody>
<tr v-for="invite in channel.data" :key="invite.hostmask">
<td class="hostmask">{{ invite.hostmask }}</td>
<td class="hostmask">
<ParsedMessage :network="network" :text="invite.hostmask" />
</td>
<td class="invitened_by">{{ invite.invited_by }}</td>
<td class="invitened_at">{{ localetime(invite.invited_at) }}</td>
</tr>
@ -18,10 +20,14 @@
</template>
<script>
import ParsedMessage from "../ParsedMessage.vue";
import localetime from "../../js/helpers/localetime";
export default {
name: "ListInvites",
components: {
ParsedMessage,
},
props: {
network: Object,
channel: Object,

View file

@ -189,6 +189,16 @@
</div>
</div>
<div class="help-item">
<div class="subject">
<span v-if="!isApple"><kbd>Alt</kbd> <kbd>M</kbd></span>
<span v-else><kbd></kbd> <kbd>M</kbd></span>
</div>
<div class="description">
<p>Toggle recent mentions popup.</p>
</div>
</div>
<div class="help-item">
<div class="subject">
<span><kbd>Esc</kbd></span>
@ -673,6 +683,15 @@
</div>
</div>
<div class="help-item">
<div class="subject">
<code>/search query</code>
</div>
<div class="description">
<p>Search for messages in the current channel / user</p>
</div>
</div>
<div class="help-item">
<div class="subject">
<code>/topic [newtopic]</code>

View file

@ -91,7 +91,7 @@
type="checkbox"
name="showSeconds"
/>
Show seconds in timestamp
Include seconds in timestamp
</label>
</div>
<div>
@ -101,7 +101,7 @@
type="checkbox"
name="use12hClock"
/>
Show 12-hour timestamps
Use 12-hour timestamps
</label>
</div>
<div v-if="!$store.state.serverConfiguration.public && $store.state.settings.advanced">
@ -306,6 +306,7 @@
<input
id="desktopNotifications"
:checked="$store.state.settings.desktopNotifications"
:disabled="$store.state.desktopNotificationState === 'nohttps'"
type="checkbox"
name="desktopNotifications"
/>
@ -316,6 +317,14 @@
>
<strong>Warning</strong>: Notifications are not supported by your browser.
</div>
<div
v-if="$store.state.desktopNotificationState === 'nohttps'"
id="warnBlockedDesktopNotifications"
class="error"
>
<strong>Warning</strong>: Notifications are only supported over HTTPS
connections.
</div>
<div
v-if="$store.state.desktopNotificationState === 'blocked'"
id="warnBlockedDesktopNotifications"

View file

@ -1147,6 +1147,7 @@ textarea.input {
#chat .chat-content {
display: flex;
flex-direction: row-reverse;
flex-grow: 1;
overflow: hidden;
position: relative;
@ -1195,7 +1196,6 @@ textarea.input {
}
#chat .show-more {
margin-top: 50px;
padding: 10px;
padding-top: 15px;
padding-bottom: 0;
@ -1508,8 +1508,11 @@ textarea.input {
}
#chat .msg[data-type="notice"] .time,
#chat .msg[data-type="wallops"] .time,
#chat .msg[data-type="notice"] .content,
#chat .msg[data-type="notice"] .user {
#chat .msg[data-type="wallops"] .content,
#chat .msg[data-type="notice"] .user,
#chat .msg[data-type="wallops"] .user {
color: #0074d9;
}
@ -1517,6 +1520,10 @@ textarea.input {
content: "Notice: ";
}
#chat .msg[data-type="wallops"] .from .user::before {
content: "Wallops: ";
}
#chat .msg[data-type="error"],
#chat .msg[data-type="error"] .from {
color: #e74c3c;
@ -1840,19 +1847,23 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
margin-top: 6px;
}
#connect .tls input {
#connect .tls input,
#connect input[name="proxyEnabled"] {
margin: 3px 10px 0 0;
}
#connect\:host {
#connect\:host,
#connect\:proxyHost {
width: 70%;
}
#connect\:port {
#connect\:port,
#connect\:proxyPort {
width: 25%;
}
#connect\:portseparator {
#connect\:portseparator,
#connect\:proxyPortSeparator {
width: 5%;
text-align: center;
display: inline-block;
@ -2624,6 +2635,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
right: 0;
transform: translateX(180px);
transition: transform 0.2s;
z-index: 1;
}
#viewport.userlist-open #chat .userlist {

View file

@ -52,8 +52,8 @@
<div id="loading">
<div class="window">
<div id="loading-status-container">
<img src="img/logo-vertical-transparent-bg.svg" class="logo" alt="The Lounge" width="256" height="170">
<img src="img/logo-vertical-transparent-bg-inverted.svg" class="logo-inverted" alt="The Lounge" width="256" height="170">
<img src="img/logo-vertical-transparent-bg.svg" class="logo" alt="" width="256" height="170">
<img src="img/logo-vertical-transparent-bg-inverted.svg" class="logo-inverted" alt="" width="256" height="170">
<p id="loading-page-message">The Lounge requires a modern browser with JavaScript enabled.</p>
</div>
<div id="loading-reload-container">

View file

@ -0,0 +1,20 @@
"use strict";
import store from "../store";
import {router} from "../router";
function input(args) {
router.push({
name: "SearchResults",
params: {
id: store.state.activeChannel.channel.id,
},
query: {
q: args.join(" "),
},
});
return true;
}
export default {input};

View file

@ -1238,8 +1238,8 @@
"credit_card": "💳",
"receipt": "🧾",
"chart": "💹",
"email": "✉️",
"envelope": "✉️",
"email": "📧",
"e_mail": "📧",
"incoming_envelope": "📨",
"envelope_with_arrow": "📩",

View file

@ -6,17 +6,24 @@ import socket from "../socket";
import store from "../store";
socket.on("more", function (data) {
const channel = store.getters.findChannel(data.chan);
const channel = store.getters.findChannel(data.chan)?.channel;
if (!channel) {
return;
}
channel.channel.moreHistoryAvailable =
data.totalMessages > channel.channel.messages.length + data.messages.length;
channel.channel.messages.unshift(...data.messages);
channel.inputHistory = channel.inputHistory.concat(
data.messages
.filter((m) => m.self && m.text && m.type === "message")
.map((m) => m.text)
.reverse()
.slice(null, 100 - channel.inputHistory.length)
);
channel.moreHistoryAvailable =
data.totalMessages > channel.messages.length + data.messages.length;
channel.messages.unshift(...data.messages);
Vue.nextTick(() => {
channel.channel.historyLoading = false;
channel.historyLoading = false;
});
});

View file

@ -12,6 +12,8 @@ function detectDesktopNotificationState() {
return "unsupported";
} else if (Notification.permission === "granted") {
return "granted";
} else if (!window.isSecureContext) {
return "nohttps";
}
return "blocked";
@ -190,7 +192,14 @@ const store = new Vuex.Store({
// TODO: This should be a mutation
channel.pendingMessage = "";
channel.inputHistoryPosition = 0;
channel.inputHistory = [""];
channel.inputHistory = [""].concat(
channel.messages
.filter((m) => m.self && m.text && m.type === "message")
.map((m) => m.text)
.reverse()
.slice(null, 99)
);
channel.historyLoading = false;
channel.scrolledToBottom = true;
channel.editTopic = false;

View file

@ -231,12 +231,12 @@ module.exports = {
// - `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
// Freenode by default:
// Libera.Chat by default:
//
// ```js
// defaults: {
// name: "Freenode",
// host: "chat.freenode.net",
// name: "Libera.Chat",
// host: "irc.libera.chat",
// port: 6697,
// password: "",
// tls: true,
@ -248,8 +248,8 @@ module.exports = {
// }
// ```
defaults: {
name: "Freenode",
host: "chat.freenode.net",
name: "Libera.Chat",
host: "irc.libera.chat",
port: 6697,
password: "",
tls: true,

View file

@ -1,7 +1,7 @@
{
"name": "thelounge",
"description": "The self-hosted Web IRC client",
"version": "4.3.0-pre.1",
"version": "4.3.0-pre.4",
"preferGlobal": true,
"bin": {
"thelounge": "index.js"
@ -43,19 +43,19 @@
"bcryptjs": "2.4.3",
"busboy": "0.3.1",
"chalk": "4.1.1",
"cheerio": "1.0.0-rc.5",
"cheerio": "1.0.0-rc.10",
"commander": "7.2.0",
"content-disposition": "0.5.3",
"express": "4.17.1",
"file-type": "16.2.0",
"filenamify": "4.2.0",
"got": "11.8.1",
"irc-framework": "4.9.0",
"got": "11.8.2",
"irc-framework": "4.11.0",
"is-utf8": "0.2.1",
"ldapjs": "2.2.3",
"linkify-it": "3.0.2",
"lodash": "4.17.20",
"mime-types": "2.1.28",
"lodash": "4.17.21",
"mime-types": "2.1.31",
"node-forge": "0.10.0",
"package-json": "6.5.0",
"read": "1.0.7",
@ -73,8 +73,8 @@
"better-sqlite3": "7.4.0"
},
"devDependencies": {
"@babel/core": "7.14.0",
"@babel/preset-env": "7.14.0",
"@babel/core": "7.14.6",
"@babel/preset-env": "7.14.7",
"@fortawesome/fontawesome-free": "5.15.3",
"@vue/server-test-utils": "1.1.3",
"@vue/test-utils": "1.1.3",
@ -83,14 +83,14 @@
"chai": "4.3.4",
"copy-webpack-plugin": "7.0.0",
"css-loader": "5.1.1",
"cssnano": "4.1.10",
"dayjs": "1.10.4",
"emoji-regex": "9.2.1",
"cssnano": "4.1.11",
"dayjs": "1.10.5",
"emoji-regex": "9.2.2",
"eslint": "7.23.0",
"eslint-config-prettier": "6.15.0",
"eslint-plugin-vue": "7.5.0",
"fuzzy": "0.1.3",
"husky": "4.3.5",
"husky": "4.3.8",
"mini-css-extract-plugin": "1.3.6",
"mocha": "8.2.1",
"mousetrap": "1.6.5",

0
scripts/generate-emoji.js Normal file → Executable file
View file

View file

@ -257,6 +257,12 @@ Client.prototype.connect = function (args, isStartup = false) {
commands: args.commands || [],
channels: channels,
ignoreList: args.ignoreList ? args.ignoreList : [],
proxyEnabled: !!args.proxyEnabled,
proxyHost: String(args.proxyHost || ""),
proxyPort: parseInt(args.proxyPort, 10),
proxyUsername: String(args.proxyUsername || ""),
proxyPassword: String(args.proxyPassword || ""),
});
// Set network lobby channel id

View file

@ -34,7 +34,7 @@ try {
createPackagesFolder();
// Merge config key-values passed as CLI options into the main config
Helper.mergeConfig(Helper.config, program.config);
Helper.mergeConfig(Helper.config, program.opts().config);
require("./start");

View file

@ -13,6 +13,8 @@ program
.on("--help", Utils.extraHelp)
.action(function (packageName) {
const fs = require("fs");
const fspromises = fs.promises;
const path = require("path");
const packageJson = require("package-json");
if (!fs.existsSync(Helper.getConfigPath())) {
@ -21,22 +23,31 @@ program
}
log.info("Retrieving information about the package...");
let readFile = null;
let isLocalFile = false;
const split = packageName.split("@");
packageName = split[0];
const packageVersion = split[1] || "latest";
if (packageName.startsWith("file:")) {
isLocalFile = true;
readFile = fspromises
.readFile(path.join(packageName.substr("file:".length), "package.json"), "utf-8")
.then((data) => JSON.parse(data));
} else {
const split = packageName.split("@");
packageName = split[0];
const packageVersion = split[1] || "latest";
packageJson(packageName, {
fullMetadata: true,
version: packageVersion,
})
readFile = packageJson(packageName, {
fullMetadata: true,
version: packageVersion,
});
}
readFile
.then((json) => {
const humanVersion = isLocalFile ? packageName : `${json.name} v${json.version}`;
if (!("thelounge" in json)) {
log.error(
`${colors.red(
json.name + " v" + json.version
)} does not have The Lounge metadata.`
);
log.error(`${colors.red(humanVersion)} does not have The Lounge metadata.`);
process.exit(1);
}
@ -47,7 +58,7 @@ program
) {
log.error(
`${colors.red(
json.name + " v" + json.version
humanVersion
)} does not support The Lounge v${Helper.getVersionNumber()}. Supported version(s): ${
json.thelounge.supports
}`
@ -56,20 +67,23 @@ program
process.exit(2);
}
log.info(`Installing ${colors.green(json.name + " v" + json.version)}...`);
return Utils.executeYarnCommand("add", "--exact", `${json.name}@${json.version}`)
log.info(`Installing ${colors.green(humanVersion)}...`);
const yarnVersion = isLocalFile ? packageName : `${json.name}@${json.version}`;
return Utils.executeYarnCommand("add", "--exact", yarnVersion)
.then(() => {
log.info(
`${colors.green(
json.name + " v" + json.version
)} has been successfully installed.`
);
log.info(`${colors.green(humanVersion)} has been successfully installed.`);
if (isLocalFile) {
// yarn v1 is buggy if a local filepath is used and doesn't update
// the lockfile properly. We need to run an install in that case
// even though that's supposed to be done by the add subcommand
return Utils.executeYarnCommand("install").catch((err) => {
throw `Failed to update lockfile after package install ${err}`;
});
}
})
.catch((code) => {
throw `Failed to install ${colors.green(
json.name + " v" + json.version
)}. Exit code: ${code}`;
throw `Failed to install ${colors.red(humanVersion)}. Exit code: ${code}`;
});
})
.catch((e) => {

View file

@ -79,6 +79,7 @@ Msg.Type = {
WHOIS: "whois",
RAW: "raw",
PLUGIN: "plugin",
WALLOPS: "wallops",
};
module.exports = Msg;

View file

@ -46,6 +46,13 @@ function Network(attr) {
PREFIX: ["!", "@", "%", "+"],
NETWORK: "",
},
proxyHost: "",
proxyPort: 1080,
proxyUsername: "",
proxyPassword: "",
proxyEnabled: false,
chanCache: [],
ignoreList: [],
keepNick: null,
@ -90,6 +97,12 @@ Network.prototype.validate = function (client) {
this.saslAccount = cleanString(this.saslAccount);
this.saslPassword = cleanString(this.saslPassword);
this.proxyHost = cleanString(this.proxyHost);
this.proxyPort = this.proxyPort || 1080;
this.proxyUsername = cleanString(this.proxyUsername);
this.proxyPassword = cleanString(this.proxyPassword);
this.proxyEnabled = !!this.proxyEnabled;
if (!this.port) {
this.port = this.tls ? 6697 : 6667;
}
@ -208,6 +221,17 @@ Network.prototype.setIrcFrameworkOptions = function (client) {
this.irc.options.webirc = this.createWebIrc(client);
this.irc.options.client_certificate = null;
if (this.proxyEnabled) {
this.irc.options.socks = {
host: this.proxyHost,
port: this.proxyPort,
user: this.proxyUsername,
pass: this.proxyPassword,
};
} else {
delete this.irc.options.socks;
}
if (!this.sasl) {
delete this.irc.options.sasl_mechanism;
delete this.irc.options.account;
@ -274,6 +298,12 @@ Network.prototype.edit = function (client, args) {
this.saslAccount = String(args.saslAccount || "");
this.saslPassword = String(args.saslPassword || "");
this.proxyHost = String(args.proxyHost || "");
this.proxyPort = parseInt(args.proxyPort, 10);
this.proxyUsername = String(args.proxyUsername || "");
this.proxyPassword = String(args.proxyPassword || "");
this.proxyEnabled = !!args.proxyEnabled;
// Split commands into an array
this.commands = String(args.commands || "")
.replace(/\r\n|\r|\n/g, "\n")
@ -455,6 +485,12 @@ Network.prototype.exportForEdit = function () {
"saslAccount",
"saslPassword",
"commands",
"proxyEnabled",
"proxyHost",
"proxyPort",
"proxyUsername",
"proxyPassword",
];
if (!Helper.config.lockNetwork) {
@ -491,6 +527,11 @@ Network.prototype.export = function () {
"saslPassword",
"commands",
"ignoreList",
"proxyHost",
"proxyPort",
"proxyUsername",
"proxyEnabled",
]);
network.channels = this.channels

View file

@ -1,4 +1,4 @@
const clientSideCommands = ["/collapse", "/expand"];
const clientSideCommands = ["/collapse", "/expand", "/search"];
const passThroughCommands = [
"/as",

View file

@ -3,10 +3,14 @@
const Chan = require("../../models/chan");
const Msg = require("../../models/msg");
exports.commands = ["mode", "op", "deop", "hop", "dehop", "voice", "devoice"];
exports.commands = ["mode", "umode", "op", "deop", "hop", "dehop", "voice", "devoice"];
exports.input = function ({irc, nick}, chan, cmd, args) {
if (cmd !== "mode") {
if (cmd === "umode") {
irc.raw("MODE", nick, ...args);
return;
} else if (cmd !== "mode") {
if (chan.type !== Chan.Type.CHANNEL) {
chan.pushMessage(
this,
@ -19,7 +23,9 @@ exports.input = function ({irc, nick}, chan, cmd, args) {
return;
}
if (args.length === 0) {
const target = args.filter((arg) => arg !== "");
if (target.length === 0) {
chan.pushMessage(
this,
new Msg({
@ -40,9 +46,13 @@ exports.input = function ({irc, nick}, chan, cmd, args) {
devoice: "-v",
}[cmd];
args.forEach(function (target) {
irc.raw("MODE", chan.name, mode, target);
});
const limit = parseInt(irc.network.supports("MODES")) || target.length;
for (let i = 0; i < target.length; i += limit) {
const targets = target.slice(i, i + limit);
const amode = `${mode[0]}${mode[1].repeat(targets.length)}`;
irc.raw("MODE", chan.name, amode, ...targets);
}
return;
}

View file

@ -222,6 +222,7 @@ function parse(msg, chan, preview, res, client) {
case "image/gif":
case "image/jpg":
case "image/jpeg":
case "image/jxl":
case "image/webp":
case "image/avif":
if (!Helper.config.prefetchStorage && Helper.config.disableMediaPreview) {

View file

@ -11,12 +11,6 @@ module.exports = function (irc, network) {
const client = this;
irc.on("notice", function (data) {
// Some servers send notices without any nickname
if (!data.nick) {
data.from_server = true;
data.nick = data.hostname || network.host;
}
data.type = Msg.Type.NOTICE;
handleMessage(data);
});
@ -33,7 +27,7 @@ module.exports = function (irc, network) {
irc.on("wallops", function (data) {
data.from_server = true;
data.type = Msg.Type.NOTICE;
data.type = Msg.Type.WALLOPS;
handleMessage(data);
});
@ -44,6 +38,12 @@ module.exports = function (irc, network) {
let showInActive = false;
const self = data.nick === irc.user.nick;
// Some servers send messages without any nickname
if (!data.nick) {
data.from_server = true;
data.nick = data.hostname || network.host;
}
// Check if the sender is in our ignore list
const shouldIgnore =
!self &&
@ -51,8 +51,13 @@ module.exports = function (irc, network) {
return Helper.compareHostmask(entry, data);
});
// Server messages go to server window, no questions asked
if (data.from_server) {
// Server messages that aren't targeted at a channel go to the server window
if (
data.from_server &&
(!data.target ||
!network.getChannel(data.target) ||
network.getChannel(data.target).type !== Chan.Type.CHANNEL)
) {
chan = network.channels[0];
from = chan.getUser(data.nick);
} else {

View file

@ -29,6 +29,7 @@ const inlineContentDispositionTypes = {
"image/png": "image.png",
"image/webp": "image.webp",
"image/avif": "image.avif",
"image/jxl": "image.jxl",
"text/plain": "text.txt",
"video/mp4": "video.mp4",
"video/ogg": "video.ogv",

View file

@ -55,8 +55,8 @@ describe("cleanIrcMessage", function () {
expected: "bold bold",
},
{
input: "\x02irc\x0f://\x1dfreenode.net\x0f/\x034,8thelounge",
expected: "irc://freenode.net/thelounge",
input: "\x02irc\x0f://\x1dirc.example.com\x0f/\x034,8thelounge",
expected: "irc://irc.example.com/thelounge",
},
{
input: "\x02#\x038,9thelounge",

View file

@ -8,12 +8,12 @@ const {
describe("findLinks", () => {
it("should find url", () => {
const input = "irc://freenode.net/thelounge";
const input = "irc://irc.example.com/thelounge";
const expected = [
{
start: 0,
end: 28,
link: "irc://freenode.net/thelounge",
end: 31,
link: "irc://irc.example.com/thelounge",
},
];

View file

@ -63,10 +63,10 @@ describe("IRC formatted message parser", () => {
it("should find urls", async () => {
const testCases = [
{
input: "irc://freenode.net/thelounge",
input: "irc://irc.example.com/thelounge",
expected:
'<a href="irc://freenode.net/thelounge" dir="auto" target="_blank" rel="noopener">' +
"irc://freenode.net/thelounge" +
'<a href="irc://irc.example.com/thelounge" dir="auto" target="_blank" rel="noopener">' +
"irc://irc.example.com/thelounge" +
"</a>",
},
{
@ -416,12 +416,12 @@ describe("IRC formatted message parser", () => {
it("should go bonkers like mirc", async () => {
const testCases = [
{
input: "\x02irc\x0f://\x1dfreenode.net\x0f/\x034,8thelounge",
input: "\x02irc\x0f://\x1dirc.example.com\x0f/\x034,8thelounge",
expected:
'<a href="irc://freenode.net/thelounge" dir="auto" target="_blank" rel="noopener">' +
'<a href="irc://irc.example.com/thelounge" dir="auto" target="_blank" rel="noopener">' +
'<span class="irc-bold">irc</span>' +
"://" +
'<span class="irc-italic">freenode.net</span>' +
'<span class="irc-italic">irc.example.com</span>' +
"/" +
'<span class="irc-fg4 irc-bg8">thelounge</span>' +
"</a>",

View file

@ -17,15 +17,38 @@ describe("Commands", function () {
});
const testableNetwork = {
firstCommand: null,
lastCommand: null,
nick: "xPaw",
irc: {
network: {
supports(type) {
if (type.toUpperCase() === "MODES") {
return "4";
}
},
},
raw(...args) {
testableNetwork.firstCommand = testableNetwork.lastCommand;
testableNetwork.lastCommand = args.join(" ");
},
},
};
const testableNetworkNoSupports = Object.assign({}, testableNetwork, {
irc: {
network: {
supports() {
return null;
},
},
raw(...args) {
testableNetworkNoSupports.firstCommand = testableNetworkNoSupports.lastCommand;
testableNetworkNoSupports.lastCommand = args.join(" ");
},
},
});
it("should not mess with the given target", function () {
const test = function (expected, args) {
ModeCommand.input(testableNetwork, channel, "mode", Array.from(args));
@ -81,10 +104,34 @@ describe("Commands", function () {
ModeCommand.input(testableNetwork, channel, "devoice", ["xPaw"]);
expect(testableNetwork.lastCommand).to.equal("MODE #thelounge -v xPaw");
});
// Multiple arguments are supported, sent as separate commands
ModeCommand.input(testableNetwork, channel, "devoice", ["xPaw", "Max-P"]);
expect(testableNetwork.lastCommand).to.equal("MODE #thelounge -v Max-P");
it("should use ISUPPORT MODES on shorthand commands", function () {
ModeCommand.input(testableNetwork, channel, "voice", ["xPaw", "Max-P"]);
expect(testableNetwork.lastCommand).to.equal("MODE #thelounge +vv xPaw Max-P");
// since the limit for modes on tests is 4, it should send two commands
ModeCommand.input(testableNetwork, channel, "devoice", [
"xPaw",
"Max-P",
"hey",
"idk",
"thelounge",
]);
expect(testableNetwork.firstCommand).to.equal(
"MODE #thelounge -vvvv xPaw Max-P hey idk"
);
expect(testableNetwork.lastCommand).to.equal("MODE #thelounge -v thelounge");
});
it("should fallback to all modes at once for shorthand commands", function () {
ModeCommand.input(testableNetworkNoSupports, channel, "voice", ["xPaw"]);
expect(testableNetworkNoSupports.lastCommand).to.equal("MODE #thelounge +v xPaw");
ModeCommand.input(testableNetworkNoSupports, channel, "devoice", ["xPaw", "Max-P"]);
expect(testableNetworkNoSupports.lastCommand).to.equal(
"MODE #thelounge -vv xPaw Max-P"
);
});
});
});

View file

@ -2,6 +2,8 @@
var config = require("../../../defaults/config.js");
config.defaults.name = "Example IRC Server";
config.defaults.host = "irc.example.com";
config.public = true;
config.prefetch = true;
config.host = config.bind = "127.0.0.1";

View file

@ -46,6 +46,10 @@ describe("Network", function () {
saslPassword: "testpassword",
commands: [],
nick: "chillin`",
proxyEnabled: false,
proxyHost: "",
proxyPort: 1080,
proxyUsername: "",
channels: [
{name: "#thelounge", key: ""},
{name: "&foobar", key: ""},
@ -91,7 +95,7 @@ describe("Network", function () {
rejectUnauthorized: false,
});
expect(network.validate()).to.be.true;
expect(network.host).to.equal("chat.freenode.net");
expect(network.host).to.equal("irc.example.com");
expect(network.port).to.equal(6697);
expect(network.tls).to.be.true;
expect(network.rejectUnauthorized).to.be.true;
@ -103,7 +107,7 @@ describe("Network", function () {
host: "some.fake.tld",
});
expect(network2.validate()).to.be.true;
expect(network2.host).to.equal("chat.freenode.net");
expect(network2.host).to.equal("irc.example.com");
Helper.config.lockNetwork = false;
});
@ -265,7 +269,7 @@ describe("Network", function () {
// Lobby and initial channel
expect(network.channels.length).to.equal(2);
const newChan = new Chan({name: "#freenode"});
const newChan = new Chan({name: "#foo"});
network.addChannel(newChan);
expect(network.channels.length).to.equal(3);
@ -278,13 +282,13 @@ describe("Network", function () {
const network = new Network({
channels: [chan1, chan2, chan3],
name: "freenode",
name: "foo",
});
const newChan = new Chan({name: "#freenode"});
const newChan = new Chan({name: "#foo"});
network.addChannel(newChan);
expect(network.channels[0].name).to.equal("freenode");
expect(network.channels[0].name).to.equal("foo");
expect(network.channels[1]).to.equal(chan1);
expect(network.channels[2]).to.equal(newChan);
expect(network.channels[3]).to.equal(chan2);
@ -299,7 +303,7 @@ describe("Network", function () {
channels: [chan1, chan2],
});
const newChan = new Chan({name: "#freenode"});
const newChan = new Chan({name: "#foo"});
network.addChannel(newChan);
expect(network.channels[1]).to.equal(chan1);
@ -393,7 +397,7 @@ describe("Network", function () {
channels: [banlist, chan1, user1],
});
const newChan = new Chan({name: "#freenode"});
const newChan = new Chan({name: "#foo"});
network.addChannel(newChan);
expect(network.channels[1]).to.equal(newChan);
@ -404,7 +408,7 @@ describe("Network", function () {
it("should never add something in front of the lobby", function () {
const network = new Network({
name: "freenode",
name: "foo",
channels: [],
});

3086
yarn.lock

File diff suppressed because it is too large Load diff