Merge branch 'master' into maxleiter/accessiblityImprovements

This commit is contained in:
Max Leiter 2021-06-06 23:49:34 -07:00 committed by GitHub
commit 0fa37a6a05
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
57 changed files with 1717 additions and 1203 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

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

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

@ -41,6 +41,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"
@ -87,7 +95,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>
@ -111,6 +124,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";
@ -124,10 +138,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"
@ -29,9 +33,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"
@ -45,7 +52,7 @@
<span v-if="message.type === 'message'" class="from">
<template v-if="message.from && message.from.nick">
<span class="only-copy" aria-hidden="true">&lt;</span>
<Username :user="message.from" />
<Username :user="message.from" :network="network" :channel="channel" />
<span class="only-copy" aria-hidden="true">&gt; </span>
</template>
</span>
@ -59,7 +66,7 @@
<span v-else class="from">
<template v-if="message.from && message.from.nick">
<span class="only-copy" aria-hidden="true">-</span>
<Username :user="message.from" />
<Username :user="message.from" :network="network" :channel="channel" />
<span class="only-copy" aria-hidden="true">- </span>
</template>
</span>
@ -111,6 +118,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,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

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

@ -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/ */ }
@ -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 */
}
@ -1187,6 +1195,7 @@ textarea.input {
}
#chat .show-more {
margin-top: 50px;
padding: 10px;
padding-top: 15px;
padding-bottom: 0;
@ -2848,3 +2857,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,10 +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 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(chan);

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: "/chan-:id",
component: RoutedChat,
},
{
name: "SearchResults",
path: "/chan-:id/search",
component: SearchResults,
},
],
});

View File

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

@ -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) {
@ -160,7 +190,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");

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

@ -42,8 +42,8 @@
"dependencies": {
"bcryptjs": "2.4.3",
"busboy": "0.3.1",
"chalk": "4.1.0",
"cheerio": "1.0.0-rc.5",
"chalk": "4.1.1",
"cheerio": "1.0.0-rc.9",
"commander": "7.2.0",
"content-disposition": "0.5.3",
"express": "4.17.1",
@ -64,7 +64,7 @@
"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"
@ -73,8 +73,8 @@
"sqlite3": "5.0.2"
},
"devDependencies": {
"@babel/core": "7.13.14",
"@babel/preset-env": "7.13.12",
"@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",
@ -83,8 +83,8 @@
"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.23.0",
"eslint-config-prettier": "6.15.0",
@ -97,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")) {
@ -111,6 +113,8 @@ function Client(manager, name, config = {}) {
client.awayMessage = client.config.clientSettings.awayMessage;
}
client.config.clientSettings.searchEnabled = client.messageProvider !== undefined;
client.compileCustomHighlights();
_.forOwn(client.config.sessions, (session) => {
@ -539,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

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

@ -189,7 +189,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

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

@ -21,13 +21,15 @@ const inlineContentDispositionTypes = {
"audio/mpeg": "audio.mp3",
"audio/ogg": "audio.ogg",
"audio/vnd.wave": "audio.wav",
"audio/flac": "audio.flac",
"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",
@ -110,10 +112,12 @@ class Uploader {
});
}
// Send a more common mime type for audio files
// so that browsers can play them correctly
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";
} else if (detectedMimeType === "audio/x-flac") {
detectedMimeType = "audio/flac";
}
res.setHeader("Content-Disposition", disposition);

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;
});
@ -265,7 +265,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 +278,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 +299,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 +393,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 +404,7 @@ describe("Network", function () {
it("should never add something in front of the lobby", function () {
const network = new Network({
name: "freenode",
name: "foo",
channels: [],
});

1992
yarn.lock

File diff suppressed because it is too large Load Diff