Merge branch 'master' into maxleiter/improvedURLs

This commit is contained in:
Max Leiter 2021-06-28 21:47:20 -07:00
commit c9a1846231
64 changed files with 2157 additions and 1296 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,21 @@ All notable changes to this project will be documented in this file.
<!-- New entries go after this line -->
## 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>
@ -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

@ -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

@ -8,6 +8,8 @@
{active: active},
{'parted-channel': channel.type === 'channel' && channel.state === 0},
{'has-draft': channel.pendingMessage},
{'has-unread': channel.unread},
{'has-highlight': channel.highlight},
{
'not-secure':
channel.type === 'lobby' && network.status.connected && !network.status.secure,

View file

@ -39,6 +39,14 @@
:network="network"
:text="channel.topic"
/></span>
<MessageSearchForm
v-if="
$store.state.settings.searchEnabled &&
['channel', 'query'].includes(channel.type)
"
:network="network"
:channel="channel"
/>
<button
class="mentions"
aria-label="Open your mentions"
@ -85,7 +93,12 @@
>
<div class="scroll-down-arrow" />
</div>
<MessageList ref="messageList" :network="network" :channel="channel" />
<MessageList
ref="messageList"
:network="network"
:channel="channel"
:focused="focused"
/>
<ChatUserList v-if="channel.type === 'channel'" :channel="channel" />
</div>
</div>
@ -109,6 +122,7 @@ import MessageList from "./MessageList.vue";
import ChatInput from "./ChatInput.vue";
import ChatUserList from "./ChatUserList.vue";
import SidebarToggle from "./SidebarToggle.vue";
import MessageSearchForm from "./MessageSearchForm.vue";
import ListBans from "./Special/ListBans.vue";
import ListInvites from "./Special/ListInvites.vue";
import ListChannels from "./Special/ListChannels.vue";
@ -122,10 +136,12 @@ export default {
ChatInput,
ChatUserList,
SidebarToggle,
MessageSearchForm,
},
props: {
network: Object,
channel: Object,
focused: String,
},
computed: {
specialComponent() {

View file

@ -183,6 +183,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

@ -28,7 +28,7 @@
<template v-if="userSearchInput.length > 0">
<Username
v-for="user in users"
:key="user.original.nick"
:key="user.original.nick + '-search'"
:on-hover="hoverUser"
:active="user.original === activeUser"
:user="user.original"

View file

@ -17,6 +17,7 @@ export default {
name: "DateMarker",
props: {
message: Object,
focused: Boolean,
},
computed: {
localeDate() {

View file

@ -3,7 +3,11 @@
:id="'msg-' + message.id"
:class="[
'msg',
{self: message.self, highlight: message.highlight, 'previous-source': isPreviousSource},
{
self: message.self,
highlight: message.highlight || focused,
'previous-source': isPreviousSource,
},
]"
:data-type="message.type"
:data-command="message.command"
@ -25,9 +29,12 @@
<template v-else-if="message.type === 'action'">
<span class="from"><span class="only-copy">* </span></span>
<span class="content" dir="auto">
<Username :user="message.from" dir="auto" />&#32;<ParsedMessage
:message="message"
/>
<Username
:user="message.from"
:network="network"
:channel="channel"
dir="auto"
/>&#32;<ParsedMessage :message="message" />
<LinkPreview
v-for="preview in message.previews"
:key="preview.link"
@ -41,7 +48,7 @@
<span v-if="message.type === 'message'" class="from">
<template v-if="message.from && message.from.nick">
<span class="only-copy">&lt;</span>
<Username :user="message.from" />
<Username :user="message.from" :network="network" :channel="channel" />
<span class="only-copy">&gt; </span>
</template>
</span>
@ -55,7 +62,7 @@
<span v-else class="from">
<template v-if="message.from && message.from.nick">
<span class="only-copy">-</span>
<Username :user="message.from" />
<Username :user="message.from" :network="network" :channel="channel" />
<span class="only-copy">- </span>
</template>
</span>
@ -107,6 +114,7 @@ export default {
network: Object,
keepScrollPosition: Function,
isPreviousSource: Boolean,
focused: Boolean,
},
computed: {
timeFormat() {

View file

@ -30,6 +30,7 @@ export default {
network: Object,
messages: Array,
keepScrollPosition: Function,
focused: Boolean,
},
data() {
return {

View file

@ -23,6 +23,7 @@
v-if="shouldDisplayDateMarker(message, id)"
:key="message.id + '-date'"
:message="message"
:focused="message.id == focused"
/>
<div
v-if="shouldDisplayUnreadMarker(message.id)"
@ -38,6 +39,7 @@
:network="network"
:keep-scroll-position="keepScrollPosition"
:messages="message.messages"
:focused="message.id == focused"
/>
<Message
v-else
@ -47,6 +49,7 @@
:message="message"
:keep-scroll-position="keepScrollPosition"
:is-previous-source="isPreviousSource(message, id)"
:focused="message.id == focused"
@toggle-link-preview="onLinkPreviewToggle"
/>
</template>
@ -75,6 +78,7 @@ export default {
props: {
network: Object,
channel: Object,
focused: String,
},
computed: {
condensedMessages() {

View file

@ -0,0 +1,153 @@
<template>
<form :class="['message-search', {opened: searchOpened}]" @submit.prevent="searchMessages">
<div class="input-wrapper">
<input
ref="searchInputField"
v-model="searchInput"
type="search"
name="search"
class="input"
placeholder="Search messages…"
@blur="closeSearch"
/>
</div>
<button
v-if="!onSearchPage"
class="search"
type="button"
aria-label="Search messages in this channel"
@mousedown.prevent="toggleSearch"
/>
</form>
</template>
<style>
form.message-search {
display: flex;
}
form.message-search .input-wrapper {
display: flex;
}
form.message-search input {
width: 100%;
height: auto !important;
margin: 7px 0;
border: 0;
color: inherit;
background-color: #fafafa;
}
form.message-search input::placeholder {
color: rgba(0, 0, 0, 0.35);
}
@media (min-width: 480px) {
form.message-search input {
min-width: 140px;
}
form.message-search input:focus {
min-width: 220px;
}
}
form.message-search .input-wrapper {
position: absolute;
top: 45px;
left: 0;
right: 0;
z-index: 1;
height: 0;
overflow: hidden;
background: var(--window-bg-color);
}
form.message-search .input-wrapper input {
margin: 7px;
}
form.message-search.opened .input-wrapper {
height: 50px;
}
#chat form.message-search button {
display: flex;
color: #607992;
}
</style>
<script>
export default {
name: "MessageSearchForm",
props: {
network: Object,
channel: Object,
},
data() {
return {
searchOpened: false,
searchInput: "",
};
},
computed: {
onSearchPage() {
return this.$route.name === "SearchResults";
},
},
watch: {
"$route.query.q"() {
this.searchInput = this.$route.query.q;
},
},
mounted() {
this.searchInput = this.$route.query.q;
this.searchOpened = this.onSearchPage;
if (!this.searchInput && this.searchOpened) {
this.$refs.searchInputField.focus();
}
},
methods: {
closeSearch() {
if (!this.onSearchPage) {
this.searchOpened = false;
}
},
toggleSearch() {
if (this.searchOpened) {
this.$refs.searchInputField.blur();
return;
}
this.searchOpened = true;
this.$refs.searchInputField.focus();
},
searchMessages(event) {
event.preventDefault();
if (!this.searchInput) {
return;
}
this.$router
.push({
name: "SearchResults",
params: {
id: this.channel.id,
},
query: {
q: this.searchInput,
},
})
.catch((err) => {
if (err.name === "NavigationDuplicated") {
// Search for the same query again
this.$root.$emit("re-search");
}
});
},
},
};
</script>

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,23 +1,25 @@
<template>
<span class="content">
<Username :user="message.from" />
<i class="hostmask"> ({{ message.hostmask }})</i>
<template v-if="message.account !== false">
<i class="hostmask"> (<ParsedMessage :network="network" :text="message.hostmask" />)</i>
<template v-if="message.account">
<i class="account"> [{{ message.account }}]</i>
</template>
<template v-if="message.gecos !== false">
<i class="realname"> {{ message.gecos }} -</i>
<template v-if="message.gecos">
<i class="realname"> {{ message.gecos }}</i>
</template>
has joined the channel
</span>
</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

@ -168,10 +168,11 @@
/>
</div>
<div class="connect-row">
<label for="connect:leaveMessage">Away message</label>
<label for="connect:leaveMessage">Leave message</label>
<input
id="connect:leaveMessage"
v-model="defaults.leaveMessage"
autocomplete="off"
class="input"
name="leaveMessage"
placeholder="The Lounge - https://thelounge.chat"
@ -193,6 +194,7 @@ the server tab on new connection"
<textarea
id="connect:commands"
ref="commandsInput"
autocomplete="off"
:value="defaults.commands ? defaults.commands.join('\n') : ''"
class="input"
name="commands"

View file

@ -1,5 +1,10 @@
<template>
<Chat v-if="activeChannel" :network="activeChannel.network" :channel="activeChannel.channel" />
<Chat
v-if="activeChannel"
:network="activeChannel.network"
:channel="activeChannel.channel"
:focused="this.$route.query.focused"
/>
</template>
<script>

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

@ -20,6 +20,8 @@ export default {
user: Object,
active: Boolean,
onHover: Function,
channel: Object,
network: Object,
},
computed: {
mode() {
@ -42,6 +44,8 @@ export default {
eventbus.emit("contextmenu:user", {
event: event,
user: this.user,
network: this.network,
channel: this.channel,
});
},
},

View file

@ -673,6 +673,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

@ -0,0 +1,246 @@
<template>
<div id="chat-container" class="window">
<div
id="chat"
:class="{
'colored-nicks': $store.state.settings.coloredNicks,
'time-seconds': $store.state.settings.showSeconds,
'time-12h': $store.state.settings.use12hClock,
}"
>
<div
class="chat-view"
data-type="search-results"
aria-label="Search results"
role="tabpanel"
>
<div class="header">
<SidebarToggle />
<span class="title"
>Searching in <span class="channel-name">{{ channel.name }}</span> for</span
>
<span class="topic">{{ $route.query.q }}</span>
<MessageSearchForm :network="network" :channel="channel" />
<button
class="close"
aria-label="Close search window"
title="Close search window"
@click="closeSearch"
/>
</div>
<div class="chat-content">
<div ref="chat" class="chat" tabindex="-1">
<div v-show="moreResultsAvailable" class="show-more">
<button
ref="loadMoreButton"
:disabled="
$store.state.messageSearchInProgress ||
!$store.state.isConnected
"
class="btn"
@click="onShowMoreClick"
>
<span v-if="$store.state.messageSearchInProgress">Loading</span>
<span v-else>Show older messages</span>
</button>
</div>
<div
v-if="$store.state.messageSearchInProgress && !offset"
class="search-status"
>
Searching
</div>
<div v-else-if="!messages.length && !offset" class="search-status">
No results found.
</div>
<div
v-else
class="messages"
role="log"
aria-live="polite"
aria-relevant="additions"
>
<template v-for="(message, id) in messages">
<div :key="message.id" class="result" @:click="jump(message, id)">
<DateMarker
v-if="shouldDisplayDateMarker(message, id)"
:key="message.date"
:message="message"
/>
<Message
:key="message.id"
:channel="channel"
:network="network"
:message="message"
:data-id="message.id"
/>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style>
.channel-name {
font-weight: 700;
}
</style>
<script>
import socket from "../../js/socket";
import SidebarToggle from "../SidebarToggle.vue";
import Message from "../Message.vue";
import MessageSearchForm from "../MessageSearchForm.vue";
import DateMarker from "../DateMarker.vue";
export default {
name: "SearchResults",
components: {
SidebarToggle,
Message,
DateMarker,
MessageSearchForm,
},
data() {
return {
offset: 0,
moreResultsAvailable: false,
oldScrollTop: 0,
oldChatHeight: 0,
};
},
computed: {
search() {
return this.$store.state.messageSearchResults;
},
messages() {
if (!this.search) {
return [];
}
return this.search.results.slice().reverse();
},
chan() {
const chanId = parseInt(this.$route.params.id, 10);
return this.$store.getters.findChannel(chanId);
},
network() {
if (!this.chan) {
return null;
}
return this.chan.network;
},
channel() {
if (!this.chan) {
return null;
}
return this.chan.channel;
},
},
watch: {
"$route.params.id"() {
this.doSearch();
this.setActiveChannel();
},
"$route.query.q"() {
this.doSearch();
this.setActiveChannel();
},
messages() {
this.moreResultsAvailable = this.messages.length && !(this.messages.length % 100);
if (!this.offset) {
this.jumpToBottom();
} else {
this.$nextTick(() => {
const currentChatHeight = this.$refs.chat.scrollHeight;
this.$refs.chat.scrollTop =
this.oldScrollTop + currentChatHeight - this.oldChatHeight;
});
}
},
},
mounted() {
this.setActiveChannel();
this.doSearch();
this.$root.$on("re-search", this.doSearch); // Enable MessageSearchForm to search for the same query again
},
beforeDestroy() {
this.$root.$off("re-search");
},
methods: {
setActiveChannel() {
this.$store.commit("activeChannel", this.chan);
},
closeSearch() {
this.$root.switchToChannel(this.channel);
},
shouldDisplayDateMarker(message, id) {
const previousMessage = this.messages[id - 1];
if (!previousMessage) {
return true;
}
return new Date(previousMessage.time).getDay() !== new Date(message.time).getDay();
},
doSearch() {
this.offset = 0;
this.$store.commit("messageSearchInProgress", true);
if (!this.offset) {
this.$store.commit("messageSearchResults", null); // Only reset if not getting offset
}
socket.emit("search", {
networkUuid: this.network.uuid,
channelName: this.channel.name,
searchTerm: this.$route.query.q,
offset: this.offset,
});
},
onShowMoreClick() {
this.offset += 100;
this.$store.commit("messageSearchInProgress", true);
this.oldScrollTop = this.$refs.chat.scrollTop;
this.oldChatHeight = this.$refs.chat.scrollHeight;
socket.emit("search", {
networkUuid: this.network.uuid,
channelName: this.channel.name,
searchTerm: this.$route.query.q,
offset: this.offset + 1,
});
},
jumpToBottom() {
this.$nextTick(() => {
const el = this.$refs.chat;
el.scrollTop = el.scrollHeight;
});
},
jump(message, id) {
// TODO: Implement jumping to messages!
// This is difficult because it means client will need to handle a potentially nonlinear message set
// (loading IntersectionObserver both before AND after the messages)
this.$router.push({
name: "MessageList",
params: {
id: this.chan.id,
},
query: {
focused: id,
},
});
},
},
};
</script>

View file

@ -3,7 +3,13 @@
<div class="header">
<SidebarToggle />
</div>
<form ref="settingsForm" class="container" @change="onChange" @submit.prevent>
<form
ref="settingsForm"
class="container"
autocomplete="off"
@change="onChange"
@submit.prevent
>
<h1 class="title">Settings</h1>
<div>
@ -172,8 +178,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"
@ -236,18 +248,11 @@
<div>
<label class="opt">
<input
:checked="$store.state.settings.uploadCanvas"
:checked="$store.state.settings.removeImageMetadata"
type="checkbox"
name="uploadCanvas"
name="removeImageMetadata"
/>
Attempt to remove metadata from images before uploading
<span
class="tooltipped tooltipped-n tooltipped-no-delay"
aria-label="This option renders the image into a canvas element to remove metadata from the image.
This may break orientation if your browser does not support that."
>
<button class="extra-help" />
</span>
Remove metadata from uploaded images
</label>
</div>
</div>
@ -353,7 +358,7 @@ This may break orientation if your browser does not support that."
Custom highlights
<span
class="tooltipped tooltipped-n tooltipped-no-delay"
aria-label="If a message contains any of these comma-separated
aria-label="If a message contains any of these comma-separated
expressions, it will trigger a highlight."
>
<button class="extra-help" />
@ -376,8 +381,8 @@ expressions, it will trigger a highlight."
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
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" />
@ -405,10 +410,11 @@ your nickname or expressions defined in custom highlights."
>
<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="current-password" class="sr-only"> Enter current password </label>
<RevealPassword v-slot:default="slotProps">
<input
id="old_password_input"
id="current-password"
autocomplete="current-password"
:type="slotProps.isVisible ? 'text' : 'password'"
name="old_password"
class="input"
@ -417,26 +423,26 @@ your nickname or expressions defined in custom highlights."
</RevealPassword>
</div>
<div class="password-container">
<label for="new_password_input" class="sr-only">
Enter desired new password
</label>
<label for="new-password" class="sr-only"> Enter desired new password </label>
<RevealPassword v-slot:default="slotProps">
<input
id="new_password_input"
id="new-password"
:type="slotProps.isVisible ? 'text' : 'password'"
name="new_password"
autocomplete="new-password"
class="input"
placeholder="Enter desired new password"
/>
</RevealPassword>
</div>
<div class="password-container">
<label for="verify_password_input" class="sr-only"> Repeat new password </label>
<label for="new-password-verify" class="sr-only"> Repeat new password </label>
<RevealPassword v-slot:default="slotProps">
<input
id="verify_password_input"
id="new-password-verify"
:type="slotProps.isVisible ? 'text' : 'password'"
name="verify_password"
autocomplete="new-password"
class="input"
placeholder="Repeat new password"
/>

View file

@ -284,7 +284,9 @@ p {
#viewport .lt::before,
#viewport .rt::before,
#chat button.mentions::before,
#chat button.close::before,
#chat button.menu::before,
#chat button.search::before,
.channel-list-item::before,
#footer .icon,
#chat .count::before,
@ -342,6 +344,8 @@ p {
#viewport .rt::before { content: "\f0c0"; /* https://fontawesome.com/icons/users?style=solid */ }
#chat button.menu::before { content: "\f142"; /* http://fontawesome.io/icon/ellipsis-v/ */ }
#chat button.mentions::before { content: "\f1fa"; /* https://fontawesome.com/icons/at?style=solid */ }
#chat button.search::before { content: "\f002"; /* https://fontawesome.com/icons/search?style=solid */ }
#chat button.close::before { content: "\f00d"; /* https://fontawesome.com/icons/times?style=solid */ }
.context-menu-join::before { content: "\f067"; /* http://fontawesome.io/icon/plus/ */ }
.context-menu-user::before { content: "\f007"; /* http://fontawesome.io/icon/user/ */ }
@ -352,8 +356,8 @@ p {
.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 */ }
@ -575,7 +579,9 @@ p {
#viewport .lt,
#viewport .rt,
#chat button.mentions,
#chat button.menu {
#chat button.search,
#chat button.menu,
#chat button.close {
color: #607992;
display: flex;
font-size: 14px;
@ -589,7 +595,9 @@ p {
#viewport .lt::before,
#viewport .rt::before,
#chat button.mentions::before,
#chat button.menu::before {
#chat button.search::before,
#chat button.menu::before,
#chat button.close::before {
width: 36px;
line-height: 36px; /* Fix alignment in Microsoft Edge */
}
@ -2848,3 +2856,10 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
#chat table.channel-list .topic {
white-space: pre-wrap;
}
.chat-view[data-type="search-results"] .search-status {
display: flex;
height: 100%;
justify-content: center;
align-items: center;
}

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

@ -184,11 +184,12 @@ export function generateChannelContextMenu($root, channel, network) {
}
export function generateUserContextMenu($root, channel, network, user) {
const currentChannelUser = channel.users.find((u) => u.nick === network.nick) || {};
const currentChannelModes = currentChannelUser.modes || [];
const currentChannelUser = channel
? channel.users.find((u) => u.nick === network.nick) || {}
: {};
const whois = () => {
const chan = $root.$store.getters.findChannelOnCurrentNetwork(user.nick);
const chan = network.channels.find((c) => c.name === user.nick);
if (chan) {
$root.switchToChannel(network, chan);
@ -246,66 +247,93 @@ export function generateUserContextMenu($root, channel, network, user) {
},
];
if (currentChannelModes.includes("@")) {
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.modes.includes("@")) {
// 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.modes.includes("+")) {
// 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

@ -87,6 +87,9 @@ function parse(createElement, text, message = undefined, network = undefined) {
const parts = channelParts.concat(linkParts).concat(emojiParts).concat(nameParts);
// The channel the message belongs to might not exist if the user isn't joined to it.
const messageChannel = message ? message.channel : null;
// Merge the styling information with the channels / URLs / nicks / text objects and
// generate HTML strings with the resulting fragments
return merge(parts, styleFragments, cleanText).map((textPart) => {
@ -184,6 +187,8 @@ function parse(createElement, text, message = undefined, network = undefined) {
user: {
nick: textPart.nick,
},
channel: messageChannel,
network,
},
attrs: {
dir: "auto",

View file

@ -13,6 +13,7 @@ import Settings from "../components/Windows/Settings.vue";
import Help from "../components/Windows/Help.vue";
import Changelog from "../components/Windows/Changelog.vue";
import NetworkEdit from "../components/Windows/NetworkEdit.vue";
import SearchResults from "../components/Windows/SearchResults.vue";
import RoutedChat from "../components/RoutedChat.vue";
import store from "./store";
@ -63,6 +64,11 @@ const router = new VueRouter({
path: "/:networkHost/:channelName?",
component: RoutedChat,
},
{
name: "SearchResults",
path: "/chan-:id/search",
component: SearchResults,
},
],
});

View file

@ -98,7 +98,7 @@ export const config = normalizeConfig({
media: {
default: true,
},
uploadCanvas: {
removeImageMetadata: {
default: true,
},
userStyles: {
@ -109,6 +109,9 @@ export const config = normalizeConfig({
}
},
},
searchEnabled: {
default: false,
},
});
export function createState() {

View file

@ -25,3 +25,4 @@ import "./changelog";
import "./setting";
import "./history_clear";
import "./mentions";
import "./search";

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

@ -0,0 +1,13 @@
import socket from "../socket";
import store from "../store";
socket.on("search:results", (response) => {
store.commit("messageSearchInProgress", false);
if (store.state.messageSearchResults) {
store.commit("addMessageSearchResults", response);
return;
}
store.commit("messageSearchResults", response);
});

View file

@ -86,6 +86,15 @@ function loadFromLocalStorage() {
storedSettings.highlights = storedSettings.highlights.join(", ");
}
// Convert deprecated uploadCanvas to removeImageMetadata
if (
storedSettings.uploadCanvas !== undefined &&
storedSettings.removeImageMetadata === undefined
) {
storedSettings.removeImageMetadata = storedSettings.uploadCanvas;
delete storedSettings.uploadCanvas;
}
return storedSettings;
}

View file

@ -38,6 +38,9 @@ const store = new Vuex.Store({
versionStatus: "loading",
versionDataExpired: false,
serverHasSettings: false,
messageSearchResults: null,
messageSearchInProgress: false,
searchEnabled: false,
},
mutations: {
appLoaded(state) {
@ -112,12 +115,39 @@ const store = new Vuex.Store({
serverHasSettings(state, value) {
state.serverHasSettings = value;
},
messageSearchInProgress(state, value) {
state.messageSearchInProgress = value;
},
messageSearchResults(state, value) {
state.messageSearchResults = value;
},
addMessageSearchResults(state, value) {
// Append the search results and add networks and channels to new messages
value.results = [...state.messageSearchResults.results, ...value.results];
state.messageSearchResults = value;
},
},
getters: {
findChannelOnCurrentNetwork: (state) => (name) => {
name = name.toLowerCase();
return state.activeChannel.network.channels.find((c) => c.name.toLowerCase() === name);
},
findChannelOnNetwork: (state) => (networkUuid, channelName) => {
for (const network of state.networks) {
if (network.uuid !== networkUuid) {
continue;
}
for (const channel of network.channels) {
if (channel.name === channelName) {
return {network, channel};
}
}
}
return null;
},
findChannel: (state) => (id) => {
for (const network of state.networks) {
for (const channel of network.channels) {
@ -177,7 +207,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

@ -26,31 +26,36 @@ class Uploader {
}
dragOver(event) {
// Prevent dragover event completely and do nothing with it
// This stops the browser from trying to guess which cursor to show
event.preventDefault();
if (event.dataTransfer.types.includes("Files")) {
// Prevent dragover event completely and do nothing with it
// This stops the browser from trying to guess which cursor to show
event.preventDefault();
}
}
dragEnter(event) {
event.preventDefault();
// relatedTarget is the target where we entered the drag from
// when dragging from another window, the target is null, otherwise its a DOM element
if (!event.relatedTarget && event.dataTransfer.types.includes("Files")) {
event.preventDefault();
this.overlay.classList.add("is-dragover");
}
}
dragLeave(event) {
event.preventDefault();
// If relatedTarget is null, that means we are no longer dragging over the page
if (!event.relatedTarget) {
event.preventDefault();
this.overlay.classList.remove("is-dragover");
}
}
drop(event) {
if (!event.dataTransfer.types.includes("Files")) {
return;
}
event.preventDefault();
this.overlay.classList.remove("is-dragover");
@ -137,46 +142,7 @@ class Uploader {
// 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 !== "image/gif"
) {
this.renderImage(file, (newFile) => this.performUpload(token, newFile));
} else {
this.performUpload(token, file);
}
}
renderImage(file, callback) {
const fileReader = new FileReader();
fileReader.onabort = () => callback(file);
fileReader.onerror = () => fileReader.abort();
fileReader.onload = () => {
const img = new Image();
img.onerror = () => callback(file);
img.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0);
canvas.toBlob((blob) => {
callback(new File([blob], file.name));
}, file.type);
};
img.src = fileReader.result;
};
fileReader.readAsDataURL(file);
this.performUpload(token, file);
}
performUpload(token, file) {
@ -219,6 +185,7 @@ class Uploader {
};
const formData = new FormData();
formData.append("removeMetadata", store.state.settings.removeImageMetadata);
formData.append("file", file);
this.xhr.open("POST", `uploads/new/${token}`);
this.xhr.send(formData);

View file

@ -114,10 +114,20 @@ body {
#viewport .rt,
#chat button.mentions,
#chat button.menu,
#chat button.close,
#form #submit {
color: #b7c5d1;
}
/* Search Form */
form.message-search input {
background-color: #28333d;
}
#chat form.message-search button {
color: #b7c5d1;
}
/* Setup text colors */
#chat .msg[data-type="error"],
#chat .msg[data-type="error"] .from {

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.2",
"preferGlobal": true,
"bin": {
"thelounge": "index.js"
@ -42,9 +42,10 @@
"dependencies": {
"bcryptjs": "2.4.3",
"busboy": "0.3.1",
"chalk": "4.1.0",
"cheerio": "1.0.0-rc.5",
"commander": "7.1.0",
"chalk": "4.1.1",
"cheerio": "1.0.0-rc.9",
"commander": "7.2.0",
"content-disposition": "0.5.3",
"express": "4.17.1",
"file-type": "16.2.0",
"filenamify": "4.2.0",
@ -60,9 +61,10 @@
"read": "1.0.7",
"read-chunk": "3.2.0",
"semver": "7.3.4",
"sharp": "0.28.0",
"socket.io": "3.1.2",
"tlds": "1.216.0",
"ua-parser-js": "0.7.23",
"ua-parser-js": "0.7.24",
"uuid": "8.3.2",
"web-push": "3.4.4",
"yarn": "1.22.10"
@ -71,20 +73,20 @@
"sqlite3": "5.0.2"
},
"devDependencies": {
"@babel/core": "7.13.8",
"@babel/preset-env": "7.13.9",
"@fortawesome/fontawesome-free": "5.15.2",
"@babel/core": "7.14.3",
"@babel/preset-env": "7.14.4",
"@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.3.0",
"chai": "4.3.4",
"copy-webpack-plugin": "7.0.0",
"css-loader": "5.1.1",
"cssnano": "4.1.10",
"dayjs": "1.10.4",
"cssnano": "4.1.11",
"dayjs": "1.10.5",
"emoji-regex": "9.2.1",
"eslint": "7.21.0",
"eslint": "7.23.0",
"eslint-config-prettier": "6.15.0",
"eslint-plugin-vue": "7.5.0",
"fuzzy": "0.1.3",
@ -95,7 +97,7 @@
"normalize.css": "8.0.1",
"npm-run-all": "4.1.5",
"nyc": "15.1.0",
"postcss": "8.2.5",
"postcss": "8.2.10",
"postcss-import": "14.0.0",
"postcss-loader": "5.0.0",
"postcss-preset-env": "6.7.0",

View file

@ -63,6 +63,7 @@ function Client(manager, name, config = {}) {
messageStorage: [],
highlightRegex: null,
highlightExceptionRegex: null,
messageProvider: undefined,
});
const client = this;
@ -72,7 +73,8 @@ function Client(manager, name, config = {}) {
if (!Helper.config.public && client.config.log) {
if (Helper.config.messageStorage.includes("sqlite")) {
client.messageStorage.push(new MessageStorage(client));
client.messageProvider = new MessageStorage(client);
client.messageStorage.push(client.messageProvider);
}
if (Helper.config.messageStorage.includes("text")) {
@ -102,10 +104,17 @@ function Client(manager, name, config = {}) {
delete client.config.awayMessage;
}
if (client.config.uploadCanvas) {
client.config.clientSettings.removeImageMetadata = client.config.uploadCanvas;
delete client.config.uploadCanvas;
}
if (client.config.clientSettings.awayMessage) {
client.awayMessage = client.config.clientSettings.awayMessage;
}
client.config.clientSettings.searchEnabled = client.messageProvider !== undefined;
client.compileCustomHighlights();
_.forOwn(client.config.sessions, (session) => {
@ -534,6 +543,14 @@ Client.prototype.clearHistory = function (data) {
}
};
Client.prototype.search = function (query) {
if (this.messageProvider === undefined) {
return Promise.resolve([]);
}
return this.messageProvider.search(query);
};
Client.prototype.open = function (socketId, target) {
// Due to how socket.io works internally, normal events may arrive later than
// the disconnect event, and because we can't control this timing precisely,

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

@ -236,17 +236,11 @@ Chan.prototype.writeUserLog = function (client, msg) {
};
Chan.prototype.loadMessages = function (client, network) {
if (!this.isLoggable()) {
if (!this.isLoggable() || !client.messageProvider) {
return;
}
const messageStorage = client.messageStorage.find((s) => s.canProvideMessages());
if (!messageStorage) {
return;
}
messageStorage
client.messageProvider
.getMessages(network, this)
.then((messages) => {
if (messages.length === 0) {

View file

@ -190,7 +190,7 @@ Network.prototype.createIrcFramework = function (client) {
// Request only new messages from ZNC if we have sqlite logging enabled
// See http://wiki.znc.in/Playback
if (client.config.log && client.messageStorage.find((s) => s.canProvideMessages())) {
if (client.messageProvider) {
this.irc.requestCap("znc.in/playback");
}
};

View file

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

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) {
@ -249,6 +250,8 @@ function parse(msg, chan, preview, res, client) {
case "audio/x-midi":
case "audio/x-mpeg":
case "audio/x-mpeg-3":
case "audio/flac":
case "audio/x-m4a":
if (!preview.link.startsWith("https://")) {
break;
}

View file

@ -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

@ -200,9 +200,70 @@ class MessageStorage {
});
}
search(query) {
if (!this.isEnabled) {
return Promise.resolve([]);
}
let select =
'SELECT msg, type, time, network, channel FROM messages WHERE type = "message" AND json_extract(msg, "$.text") LIKE ?';
const params = [`%${query.searchTerm}%`];
if (query.networkUuid) {
select += " AND network = ? ";
params.push(query.networkUuid);
}
if (query.channelName) {
select += " AND channel = ? ";
params.push(query.channelName.toLowerCase());
}
const maxResults = 100;
select += " ORDER BY time DESC LIMIT ? OFFSET ? ";
params.push(maxResults);
query.offset = parseInt(query.offset, 10) || 0;
params.push(query.offset);
return new Promise((resolve, reject) => {
this.database.all(select, params, (err, rows) => {
if (err) {
reject(err);
} else {
const response = {
searchTerm: query.searchTerm,
target: query.channelName,
networkUuid: query.networkUuid,
offset: query.offset,
results: parseSearchRowsToMessages(query.offset, rows),
};
resolve(response);
}
});
});
}
canProvideMessages() {
return this.isEnabled;
}
}
module.exports = MessageStorage;
function parseSearchRowsToMessages(id, rows) {
const messages = [];
for (const row of rows) {
const msg = JSON.parse(row.msg);
msg.time = row.time;
msg.type = row.type;
msg.networkUuid = row.network;
msg.channelName = row.channel;
msg.id = id;
messages.push(new Msg(msg));
id += 1;
}
return messages;
}

View file

@ -10,26 +10,31 @@ const readChunk = require("read-chunk");
const crypto = require("crypto");
const isUtf8 = require("is-utf8");
const log = require("../log");
const contentDisposition = require("content-disposition");
const sharp = require("sharp");
// List of allowed mime types that can be rendered in browser
// without forcing it to be downloaded
const inlineContentDispositionTypes = [
"application/ogg",
"audio/midi",
"audio/mpeg",
"audio/ogg",
"audio/vnd.wave",
"image/bmp",
"image/gif",
"image/jpeg",
"image/png",
"image/webp",
"image/avif",
"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/x-flac": "audio.flac",
"audio/x-m4a": "audio.m4a",
"image/bmp": "image.bmp",
"image/gif": "image.gif",
"image/jpeg": "image.jpg",
"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",
"video/webm": "video.webm",
};
const uploadTokens = new Map();
@ -92,17 +97,30 @@ class Uploader {
}
// Force a download in the browser if it's not an allowed type (binary or otherwise unknown)
const contentDisposition = inlineContentDispositionTypes.includes(detectedMimeType)
? "inline"
: "attachment";
let slug = req.params.slug;
const isInline = detectedMimeType in inlineContentDispositionTypes;
let disposition = isInline ? "inline" : "attachment";
if (detectedMimeType === "audio/vnd.wave") {
// Send a more common mime type for wave audio files
// so that browsers can play them correctly
detectedMimeType = "audio/wav";
if (!slug && isInline) {
slug = inlineContentDispositionTypes[detectedMimeType];
}
res.setHeader("Content-Disposition", contentDisposition);
if (slug) {
disposition = contentDisposition(slug.trim(), {
fallback: false,
type: disposition,
});
}
// Send a more common mime type for audio files
// so that browsers can play them correctly
if (detectedMimeType === "audio/vnd.wave") {
detectedMimeType = "audio/wav";
} else if (detectedMimeType === "audio/x-flac") {
detectedMimeType = "audio/flac";
}
res.setHeader("Content-Disposition", disposition);
res.setHeader("Cache-Control", "max-age=86400");
res.contentType(detectedMimeType);
@ -116,6 +134,7 @@ class Uploader {
let destDir;
let destPath;
let streamWriter;
let removeMetadata;
const doneCallback = () => {
// detach the stream and drain any remaining data
@ -134,6 +153,19 @@ class Uploader {
}
};
const successfullCompletion = () => {
doneCallback();
if (!uploadUrl) {
return res.status(400).json({error: "Missing file"});
}
// upload was done, send the generated file url to the client
res.status(200).json({
url: uploadUrl,
});
};
const abortWithError = (err) => {
doneCallback();
@ -180,6 +212,11 @@ class Uploader {
busboyInstance.on("partsLimit", () => abortWithError(Error("Parts limit reached")));
busboyInstance.on("filesLimit", () => abortWithError(Error("Files limit reached")));
busboyInstance.on("fieldsLimit", () => abortWithError(Error("Fields limit reached")));
busboyInstance.on("field", (fieldname, val) => {
if (fieldname === "removeMetadata") {
removeMetadata = val === "true";
}
});
// generate a random output filename for the file
// we use do/while loop to prevent the rare case of generating a file name
@ -200,11 +237,7 @@ class Uploader {
return abortWithError(err);
}
// Open a file stream for writing
streamWriter = fs.createWriteStream(destPath);
streamWriter.on("error", abortWithError);
busboyInstance.on("file", (fieldname, fileStream, filename) => {
busboyInstance.on("file", (fieldname, fileStream, filename, encoding, contentType) => {
uploadUrl = `${randomName}/${encodeURIComponent(filename)}`;
if (Helper.config.fileUpload.baseUrl) {
@ -213,31 +246,55 @@ class Uploader {
uploadUrl = `uploads/${uploadUrl}`;
}
// Sharps prebuilt libvips does not include gif support, but that is not a problem,
// as GIFs don't support EXIF metadata or anything alike
const isImage = contentType.startsWith("image/") && !contentType.endsWith("gif");
// if the busboy data stream errors out or goes over the file size limit
// abort the processing with an error
fileStream.on("error", abortWithError);
fileStream.on("limit", () => {
fileStream.unpipe(streamWriter);
if (!isImage) {
fileStream.unpipe(streamWriter);
}
fileStream.on("readable", fileStream.read.bind(fileStream));
abortWithError(Error("File size limit reached"));
});
// Attempt to write the stream to file
fileStream.pipe(streamWriter);
});
if (isImage) {
let sharpInstance = sharp({
animated: true,
pages: -1,
sequentialRead: true,
});
busboyInstance.on("finish", () => {
doneCallback();
if (!removeMetadata) {
sharpInstance = sharpInstance.withMetadata();
}
if (!uploadUrl) {
return res.status(400).json({error: "Missing file"});
sharpInstance
.rotate() // auto-orient based on the EXIF Orientation tag
.toFile(destPath, (err) => {
// Removes metadata by default https://sharp.pixelplumbing.com/api-output#tofile if no `withMetadata` is present
if (err) {
abortWithError(err);
} else {
successfullCompletion();
}
});
fileStream.pipe(sharpInstance);
} else {
// Open a file stream for writing
streamWriter = fs.createWriteStream(destPath);
streamWriter.on("error", abortWithError);
streamWriter.on("finish", successfullCompletion);
// Attempt to write the stream to file
fileStream.pipe(streamWriter);
}
// upload was done, send the generated file url to the client
res.status(200).json({
url: uploadUrl,
});
});
// pipe request body to busboy for processing

View file

@ -643,6 +643,12 @@ function initializeClient(socket, client, token, lastMessage, openChannel) {
const clientSettings = client.config.clientSettings;
socket.emit("setting:all", clientSettings);
});
socket.on("search", (query) => {
client.search(query).then((results) => {
socket.emit("search:results", results);
});
});
}
socket.on("sign-out", (tokenToSignOut) => {

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

@ -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

@ -91,7 +91,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 +103,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;
});
@ -273,7 +273,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);
@ -286,13 +286,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);
@ -307,7 +307,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);
@ -401,7 +401,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);
@ -412,7 +412,7 @@ describe("Network", function () {
it("should never add something in front of the lobby", function () {
const network = new Network({
name: "freenode",
name: "foo",
channels: [],
});

View file

@ -85,6 +85,7 @@ const config = {
},
externals: {
json3: "JSON", // socket.io uses json3.js, but we do not target any browsers that need it
sharp: "commonjs sharp",
},
plugins: [
new VueLoaderPlugin(),

2124
yarn.lock

File diff suppressed because it is too large Load diff