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 root: true
parserOptions: parserOptions:
ecmaVersion: 2018 ecmaVersion: 2020
env: env:
es6: true es6: true

View file

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

View file

@ -4,7 +4,7 @@ about: Request a new feature
labels: "Type: 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. --> <!-- Make sure to check the existing issues prior to submitting your suggestion. -->
### Feature Description ### Feature Description

View file

@ -13,4 +13,4 @@ contact_links:
- name: General support - name: General support
url: https://demo.thelounge.chat/?join=%23thelounge 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) - 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) 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). (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 right away, but this channel is full of nice people who will be happy to
help you. help you.

View file

@ -20,8 +20,8 @@
</p> </p>
<p align="center"> <p align="center">
<a href="https://demo.thelounge.chat/"><img <a href="https://demo.thelounge.chat/"><img
alt="#thelounge IRC channel on freenode" alt="#thelounge IRC channel on Libera.Chat"
src="https://img.shields.io/badge/freenode-%23thelounge-415364.svg?colorA=ff9e18"></a> src="https://img.shields.io/badge/Libera.Chat-%23thelounge-415364.svg?colorA=ff9e18"></a>
<a href="https://yarn.pm/thelounge"><img <a href="https://yarn.pm/thelounge"><img
alt="npm version" alt="npm version"
src="https://img.shields.io/npm/v/thelounge.svg?colorA=333a41&maxAge=3600"></a> 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 - Contact us privately first, in a
[responsible disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure) [responsible disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure)
manner. 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`. `#thelounge`.
- By email, send us your report at <security@thelounge.chat>. - By email, send us your report at <security@thelounge.chat>.

View file

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

View file

@ -41,6 +41,14 @@
:network="network" :network="network"
:text="channel.topic" :text="channel.topic"
/></span> /></span>
<MessageSearchForm
v-if="
$store.state.settings.searchEnabled &&
['channel', 'query'].includes(channel.type)
"
:network="network"
:channel="channel"
/>
<button <button
class="mentions" class="mentions"
aria-label="Open your mentions" aria-label="Open your mentions"
@ -87,7 +95,12 @@
> >
<div class="scroll-down-arrow" /> <div class="scroll-down-arrow" />
</div> </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" /> <ChatUserList v-if="channel.type === 'channel'" :channel="channel" />
</div> </div>
</div> </div>
@ -111,6 +124,7 @@ import MessageList from "./MessageList.vue";
import ChatInput from "./ChatInput.vue"; import ChatInput from "./ChatInput.vue";
import ChatUserList from "./ChatUserList.vue"; import ChatUserList from "./ChatUserList.vue";
import SidebarToggle from "./SidebarToggle.vue"; import SidebarToggle from "./SidebarToggle.vue";
import MessageSearchForm from "./MessageSearchForm.vue";
import ListBans from "./Special/ListBans.vue"; import ListBans from "./Special/ListBans.vue";
import ListInvites from "./Special/ListInvites.vue"; import ListInvites from "./Special/ListInvites.vue";
import ListChannels from "./Special/ListChannels.vue"; import ListChannels from "./Special/ListChannels.vue";
@ -124,10 +138,12 @@ export default {
ChatInput, ChatInput,
ChatUserList, ChatUserList,
SidebarToggle, SidebarToggle,
MessageSearchForm,
}, },
props: { props: {
network: Object, network: Object,
channel: Object, channel: Object,
focused: String,
}, },
computed: { computed: {
specialComponent() { specialComponent() {

View file

@ -183,6 +183,10 @@ export default {
}, },
setInputSize() { setInputSize() {
this.$nextTick(() => { this.$nextTick(() => {
if (!this.$refs.input) {
return;
}
const style = window.getComputedStyle(this.$refs.input); const style = window.getComputedStyle(this.$refs.input);
const lineHeight = parseFloat(style.lineHeight, 10) || 1; const lineHeight = parseFloat(style.lineHeight, 10) || 1;

View file

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

View file

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

View file

@ -3,7 +3,11 @@
:id="'msg-' + message.id" :id="'msg-' + message.id"
:class="[ :class="[
'msg', '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-type="message.type"
:data-command="message.command" :data-command="message.command"
@ -29,9 +33,12 @@
<template v-else-if="message.type === 'action'"> <template v-else-if="message.type === 'action'">
<span class="from"><span class="only-copy">* </span></span> <span class="from"><span class="only-copy">* </span></span>
<span class="content" dir="auto"> <span class="content" dir="auto">
<Username :user="message.from" dir="auto" />&#32;<ParsedMessage <Username
:message="message" :user="message.from"
/> :network="network"
:channel="channel"
dir="auto"
/>&#32;<ParsedMessage :message="message" />
<LinkPreview <LinkPreview
v-for="preview in message.previews" v-for="preview in message.previews"
:key="preview.link" :key="preview.link"
@ -45,7 +52,7 @@
<span v-if="message.type === 'message'" class="from"> <span v-if="message.type === 'message'" class="from">
<template v-if="message.from && message.from.nick"> <template v-if="message.from && message.from.nick">
<span class="only-copy" aria-hidden="true">&lt;</span> <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> <span class="only-copy" aria-hidden="true">&gt; </span>
</template> </template>
</span> </span>
@ -59,7 +66,7 @@
<span v-else class="from"> <span v-else class="from">
<template v-if="message.from && message.from.nick"> <template v-if="message.from && message.from.nick">
<span class="only-copy" aria-hidden="true">-</span> <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> <span class="only-copy" aria-hidden="true">- </span>
</template> </template>
</span> </span>
@ -111,6 +118,7 @@ export default {
network: Object, network: Object,
keepScrollPosition: Function, keepScrollPosition: Function,
isPreviousSource: Boolean, isPreviousSource: Boolean,
focused: Boolean,
}, },
computed: { computed: {
timeFormat() { timeFormat() {

View file

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

View file

@ -23,6 +23,7 @@
v-if="shouldDisplayDateMarker(message, id)" v-if="shouldDisplayDateMarker(message, id)"
:key="message.id + '-date'" :key="message.id + '-date'"
:message="message" :message="message"
:focused="message.id == focused"
/> />
<div <div
v-if="shouldDisplayUnreadMarker(message.id)" v-if="shouldDisplayUnreadMarker(message.id)"
@ -38,6 +39,7 @@
:network="network" :network="network"
:keep-scroll-position="keepScrollPosition" :keep-scroll-position="keepScrollPosition"
:messages="message.messages" :messages="message.messages"
:focused="message.id == focused"
/> />
<Message <Message
v-else v-else
@ -47,6 +49,7 @@
:message="message" :message="message"
:keep-scroll-position="keepScrollPosition" :keep-scroll-position="keepScrollPosition"
:is-previous-source="isPreviousSource(message, id)" :is-previous-source="isPreviousSource(message, id)"
:focused="message.id == focused"
@toggle-link-preview="onLinkPreviewToggle" @toggle-link-preview="onLinkPreviewToggle"
/> />
</template> </template>
@ -75,6 +78,7 @@ export default {
props: { props: {
network: Object, network: Object,
channel: Object, channel: Object,
focused: String,
}, },
computed: { computed: {
condensedMessages() { 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 >username to <b>{{ message.new_ident }}</b></span
> >
<span v-if="message.new_host" <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> </span>
</template> </template>
<script> <script>
import ParsedMessage from "../ParsedMessage.vue";
import Username from "../Username.vue"; import Username from "../Username.vue";
export default { export default {
name: "MessageTypeChangeHost", name: "MessageTypeChangeHost",
components: { components: {
ParsedMessage,
Username, Username,
}, },
props: { props: {

View file

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

View file

@ -1,7 +1,8 @@
<template> <template>
<span class="content"> <span class="content">
<Username :user="message.from" /> <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" <i v-if="message.text" class="part-reason"
>(<ParsedMessage :network="network" :message="message" />)</i >(<ParsedMessage :network="network" :message="message" />)</i
> >

View file

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

View file

@ -12,7 +12,12 @@
</template> </template>
<dt>Host mask:</dt> <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"> <template v-if="message.whois.actual_hostname">
<dt>Actual host:</dt> <dt>Actual host:</dt>

View file

@ -1,5 +1,10 @@
<template> <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> </template>
<script> <script>

View file

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

View file

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

View file

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

View file

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

View file

@ -673,6 +673,15 @@
</div> </div>
</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="help-item">
<div class="subject"> <div class="subject">
<code>/topic [newtopic]</code> <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 .lt::before,
#viewport .rt::before, #viewport .rt::before,
#chat button.mentions::before, #chat button.mentions::before,
#chat button.close::before,
#chat button.menu::before, #chat button.menu::before,
#chat button.search::before,
.channel-list-item::before, .channel-list-item::before,
#footer .icon, #footer .icon,
#chat .count::before, #chat .count::before,
@ -342,6 +344,8 @@ p {
#viewport .rt::before { content: "\f0c0"; /* https://fontawesome.com/icons/users?style=solid */ } #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.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.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-join::before { content: "\f067"; /* http://fontawesome.io/icon/plus/ */ }
.context-menu-user::before { content: "\f007"; /* http://fontawesome.io/icon/user/ */ } .context-menu-user::before { content: "\f007"; /* http://fontawesome.io/icon/user/ */ }
@ -575,7 +579,9 @@ p {
#viewport .lt, #viewport .lt,
#viewport .rt, #viewport .rt,
#chat button.mentions, #chat button.mentions,
#chat button.menu { #chat button.search,
#chat button.menu,
#chat button.close {
color: #607992; color: #607992;
display: flex; display: flex;
font-size: 14px; font-size: 14px;
@ -589,7 +595,9 @@ p {
#viewport .lt::before, #viewport .lt::before,
#viewport .rt::before, #viewport .rt::before,
#chat button.mentions::before, #chat button.mentions::before,
#chat button.menu::before { #chat button.search::before,
#chat button.menu::before,
#chat button.close::before {
width: 36px; width: 36px;
line-height: 36px; /* Fix alignment in Microsoft Edge */ line-height: 36px; /* Fix alignment in Microsoft Edge */
} }
@ -1187,6 +1195,7 @@ textarea.input {
} }
#chat .show-more { #chat .show-more {
margin-top: 50px;
padding: 10px; padding: 10px;
padding-top: 15px; padding-top: 15px;
padding-bottom: 0; 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 { #chat table.channel-list .topic {
white-space: pre-wrap; 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) { 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 whois = () => {
const chan = $root.$store.getters.findChannelOnCurrentNetwork(user.nick); const chan = network.channels.find((c) => c.name === user.nick);
if (chan) { if (chan) {
$root.switchToChannel(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); 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 // Merge the styling information with the channels / URLs / nicks / text objects and
// generate HTML strings with the resulting fragments // generate HTML strings with the resulting fragments
return merge(parts, styleFragments, cleanText).map((textPart) => { return merge(parts, styleFragments, cleanText).map((textPart) => {
@ -184,6 +187,8 @@ function parse(createElement, text, message = undefined, network = undefined) {
user: { user: {
nick: textPart.nick, nick: textPart.nick,
}, },
channel: messageChannel,
network,
}, },
attrs: { attrs: {
dir: "auto", dir: "auto",

View file

@ -13,6 +13,7 @@ import Settings from "../components/Windows/Settings.vue";
import Help from "../components/Windows/Help.vue"; import Help from "../components/Windows/Help.vue";
import Changelog from "../components/Windows/Changelog.vue"; import Changelog from "../components/Windows/Changelog.vue";
import NetworkEdit from "../components/Windows/NetworkEdit.vue"; import NetworkEdit from "../components/Windows/NetworkEdit.vue";
import SearchResults from "../components/Windows/SearchResults.vue";
import RoutedChat from "../components/RoutedChat.vue"; import RoutedChat from "../components/RoutedChat.vue";
import store from "./store"; import store from "./store";
@ -63,6 +64,11 @@ const router = new VueRouter({
path: "/chan-:id", path: "/chan-:id",
component: RoutedChat, 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() { export function createState() {

View file

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

View file

@ -6,17 +6,24 @@ import socket from "../socket";
import store from "../store"; import store from "../store";
socket.on("more", function (data) { socket.on("more", function (data) {
const channel = store.getters.findChannel(data.chan); const channel = store.getters.findChannel(data.chan)?.channel;
if (!channel) { if (!channel) {
return; return;
} }
channel.channel.moreHistoryAvailable = channel.inputHistory = channel.inputHistory.concat(
data.totalMessages > channel.channel.messages.length + data.messages.length; data.messages
channel.channel.messages.unshift(...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(() => { 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", versionStatus: "loading",
versionDataExpired: false, versionDataExpired: false,
serverHasSettings: false, serverHasSettings: false,
messageSearchResults: null,
messageSearchInProgress: false,
searchEnabled: false,
}, },
mutations: { mutations: {
appLoaded(state) { appLoaded(state) {
@ -112,12 +115,39 @@ const store = new Vuex.Store({
serverHasSettings(state, value) { serverHasSettings(state, value) {
state.serverHasSettings = 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: { getters: {
findChannelOnCurrentNetwork: (state) => (name) => { findChannelOnCurrentNetwork: (state) => (name) => {
name = name.toLowerCase(); name = name.toLowerCase();
return state.activeChannel.network.channels.find((c) => c.name.toLowerCase() === name); 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) => { findChannel: (state) => (id) => {
for (const network of state.networks) { for (const network of state.networks) {
for (const channel of network.channels) { for (const channel of network.channels) {
@ -160,7 +190,14 @@ const store = new Vuex.Store({
// TODO: This should be a mutation // TODO: This should be a mutation
channel.pendingMessage = ""; channel.pendingMessage = "";
channel.inputHistoryPosition = 0; 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.historyLoading = false;
channel.scrolledToBottom = true; channel.scrolledToBottom = true;
channel.editTopic = false; channel.editTopic = false;

View file

@ -26,31 +26,36 @@ class Uploader {
} }
dragOver(event) { dragOver(event) {
// Prevent dragover event completely and do nothing with it if (event.dataTransfer.types.includes("Files")) {
// This stops the browser from trying to guess which cursor to show // Prevent dragover event completely and do nothing with it
event.preventDefault(); // This stops the browser from trying to guess which cursor to show
event.preventDefault();
}
} }
dragEnter(event) { dragEnter(event) {
event.preventDefault();
// relatedTarget is the target where we entered the drag from // relatedTarget is the target where we entered the drag from
// when dragging from another window, the target is null, otherwise its a DOM element // when dragging from another window, the target is null, otherwise its a DOM element
if (!event.relatedTarget && event.dataTransfer.types.includes("Files")) { if (!event.relatedTarget && event.dataTransfer.types.includes("Files")) {
event.preventDefault();
this.overlay.classList.add("is-dragover"); this.overlay.classList.add("is-dragover");
} }
} }
dragLeave(event) { dragLeave(event) {
event.preventDefault();
// If relatedTarget is null, that means we are no longer dragging over the page // If relatedTarget is null, that means we are no longer dragging over the page
if (!event.relatedTarget) { if (!event.relatedTarget) {
event.preventDefault();
this.overlay.classList.remove("is-dragover"); this.overlay.classList.remove("is-dragover");
} }
} }
drop(event) { drop(event) {
if (!event.dataTransfer.types.includes("Files")) {
return;
}
event.preventDefault(); event.preventDefault();
this.overlay.classList.remove("is-dragover"); this.overlay.classList.remove("is-dragover");

View file

@ -114,10 +114,20 @@ body {
#viewport .rt, #viewport .rt,
#chat button.mentions, #chat button.mentions,
#chat button.menu, #chat button.menu,
#chat button.close,
#form #submit { #form #submit {
color: #b7c5d1; color: #b7c5d1;
} }
/* Search Form */
form.message-search input {
background-color: #28333d;
}
#chat form.message-search button {
color: #b7c5d1;
}
/* Setup text colors */ /* Setup text colors */
#chat .msg[data-type="error"], #chat .msg[data-type="error"],
#chat .msg[data-type="error"] .from { #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. // - `join`: Comma-separated list of channels to auto-join once connected.
// //
// This value is set to connect to the official channel of The Lounge on // This value is set to connect to the official channel of The Lounge on
// Freenode by default: // Libera.Chat by default:
// //
// ```js // ```js
// defaults: { // defaults: {
// name: "Freenode", // name: "Libera.Chat",
// host: "chat.freenode.net", // host: "irc.libera.chat",
// port: 6697, // port: 6697,
// password: "", // password: "",
// tls: true, // tls: true,
@ -248,8 +248,8 @@ module.exports = {
// } // }
// ``` // ```
defaults: { defaults: {
name: "Freenode", name: "Libera.Chat",
host: "chat.freenode.net", host: "irc.libera.chat",
port: 6697, port: 6697,
password: "", password: "",
tls: true, tls: true,

View file

@ -42,8 +42,8 @@
"dependencies": { "dependencies": {
"bcryptjs": "2.4.3", "bcryptjs": "2.4.3",
"busboy": "0.3.1", "busboy": "0.3.1",
"chalk": "4.1.0", "chalk": "4.1.1",
"cheerio": "1.0.0-rc.5", "cheerio": "1.0.0-rc.9",
"commander": "7.2.0", "commander": "7.2.0",
"content-disposition": "0.5.3", "content-disposition": "0.5.3",
"express": "4.17.1", "express": "4.17.1",
@ -64,7 +64,7 @@
"sharp": "0.28.0", "sharp": "0.28.0",
"socket.io": "3.1.2", "socket.io": "3.1.2",
"tlds": "1.216.0", "tlds": "1.216.0",
"ua-parser-js": "0.7.23", "ua-parser-js": "0.7.24",
"uuid": "8.3.2", "uuid": "8.3.2",
"web-push": "3.4.4", "web-push": "3.4.4",
"yarn": "1.22.10" "yarn": "1.22.10"
@ -73,8 +73,8 @@
"sqlite3": "5.0.2" "sqlite3": "5.0.2"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.13.14", "@babel/core": "7.14.3",
"@babel/preset-env": "7.13.12", "@babel/preset-env": "7.14.4",
"@fortawesome/fontawesome-free": "5.15.3", "@fortawesome/fontawesome-free": "5.15.3",
"@vue/server-test-utils": "1.1.3", "@vue/server-test-utils": "1.1.3",
"@vue/test-utils": "1.1.3", "@vue/test-utils": "1.1.3",
@ -83,8 +83,8 @@
"chai": "4.3.4", "chai": "4.3.4",
"copy-webpack-plugin": "7.0.0", "copy-webpack-plugin": "7.0.0",
"css-loader": "5.1.1", "css-loader": "5.1.1",
"cssnano": "4.1.10", "cssnano": "4.1.11",
"dayjs": "1.10.4", "dayjs": "1.10.5",
"emoji-regex": "9.2.1", "emoji-regex": "9.2.1",
"eslint": "7.23.0", "eslint": "7.23.0",
"eslint-config-prettier": "6.15.0", "eslint-config-prettier": "6.15.0",
@ -97,7 +97,7 @@
"normalize.css": "8.0.1", "normalize.css": "8.0.1",
"npm-run-all": "4.1.5", "npm-run-all": "4.1.5",
"nyc": "15.1.0", "nyc": "15.1.0",
"postcss": "8.2.5", "postcss": "8.2.10",
"postcss-import": "14.0.0", "postcss-import": "14.0.0",
"postcss-loader": "5.0.0", "postcss-loader": "5.0.0",
"postcss-preset-env": "6.7.0", "postcss-preset-env": "6.7.0",

View file

@ -63,6 +63,7 @@ function Client(manager, name, config = {}) {
messageStorage: [], messageStorage: [],
highlightRegex: null, highlightRegex: null,
highlightExceptionRegex: null, highlightExceptionRegex: null,
messageProvider: undefined,
}); });
const client = this; const client = this;
@ -72,7 +73,8 @@ function Client(manager, name, config = {}) {
if (!Helper.config.public && client.config.log) { if (!Helper.config.public && client.config.log) {
if (Helper.config.messageStorage.includes("sqlite")) { 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")) { if (Helper.config.messageStorage.includes("text")) {
@ -111,6 +113,8 @@ function Client(manager, name, config = {}) {
client.awayMessage = client.config.clientSettings.awayMessage; client.awayMessage = client.config.clientSettings.awayMessage;
} }
client.config.clientSettings.searchEnabled = client.messageProvider !== undefined;
client.compileCustomHighlights(); client.compileCustomHighlights();
_.forOwn(client.config.sessions, (session) => { _.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) { Client.prototype.open = function (socketId, target) {
// Due to how socket.io works internally, normal events may arrive later than // 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, // the disconnect event, and because we can't control this timing precisely,

View file

@ -13,6 +13,8 @@ program
.on("--help", Utils.extraHelp) .on("--help", Utils.extraHelp)
.action(function (packageName) { .action(function (packageName) {
const fs = require("fs"); const fs = require("fs");
const fspromises = fs.promises;
const path = require("path");
const packageJson = require("package-json"); const packageJson = require("package-json");
if (!fs.existsSync(Helper.getConfigPath())) { if (!fs.existsSync(Helper.getConfigPath())) {
@ -21,22 +23,31 @@ program
} }
log.info("Retrieving information about the package..."); log.info("Retrieving information about the package...");
let readFile = null;
let isLocalFile = false;
const split = packageName.split("@"); if (packageName.startsWith("file:")) {
packageName = split[0]; isLocalFile = true;
const packageVersion = split[1] || "latest"; 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, { readFile = packageJson(packageName, {
fullMetadata: true, fullMetadata: true,
version: packageVersion, version: packageVersion,
}) });
}
readFile
.then((json) => { .then((json) => {
const humanVersion = isLocalFile ? packageName : `${json.name} v${json.version}`;
if (!("thelounge" in json)) { if (!("thelounge" in json)) {
log.error( log.error(`${colors.red(humanVersion)} does not have The Lounge metadata.`);
`${colors.red(
json.name + " v" + json.version
)} does not have The Lounge metadata.`
);
process.exit(1); process.exit(1);
} }
@ -47,7 +58,7 @@ program
) { ) {
log.error( log.error(
`${colors.red( `${colors.red(
json.name + " v" + json.version humanVersion
)} does not support The Lounge v${Helper.getVersionNumber()}. Supported version(s): ${ )} does not support The Lounge v${Helper.getVersionNumber()}. Supported version(s): ${
json.thelounge.supports json.thelounge.supports
}` }`
@ -56,20 +67,23 @@ program
process.exit(2); process.exit(2);
} }
log.info(`Installing ${colors.green(json.name + " v" + json.version)}...`); log.info(`Installing ${colors.green(humanVersion)}...`);
const yarnVersion = isLocalFile ? packageName : `${json.name}@${json.version}`;
return Utils.executeYarnCommand("add", "--exact", `${json.name}@${json.version}`) return Utils.executeYarnCommand("add", "--exact", yarnVersion)
.then(() => { .then(() => {
log.info( log.info(`${colors.green(humanVersion)} has been successfully installed.`);
`${colors.green(
json.name + " v" + json.version if (isLocalFile) {
)} has been successfully installed.` // 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) => { .catch((code) => {
throw `Failed to install ${colors.green( throw `Failed to install ${colors.red(humanVersion)}. Exit code: ${code}`;
json.name + " v" + json.version
)}. Exit code: ${code}`;
}); });
}) })
.catch((e) => { .catch((e) => {

View file

@ -236,17 +236,11 @@ Chan.prototype.writeUserLog = function (client, msg) {
}; };
Chan.prototype.loadMessages = function (client, network) { Chan.prototype.loadMessages = function (client, network) {
if (!this.isLoggable()) { if (!this.isLoggable() || !client.messageProvider) {
return; return;
} }
const messageStorage = client.messageStorage.find((s) => s.canProvideMessages()); client.messageProvider
if (!messageStorage) {
return;
}
messageStorage
.getMessages(network, this) .getMessages(network, this)
.then((messages) => { .then((messages) => {
if (messages.length === 0) { 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 // Request only new messages from ZNC if we have sqlite logging enabled
// See http://wiki.znc.in/Playback // 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"); this.irc.requestCap("znc.in/playback");
} }
}; };

View file

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

View file

@ -222,6 +222,7 @@ function parse(msg, chan, preview, res, client) {
case "image/gif": case "image/gif":
case "image/jpg": case "image/jpg":
case "image/jpeg": case "image/jpeg":
case "image/jxl":
case "image/webp": case "image/webp":
case "image/avif": case "image/avif":
if (!Helper.config.prefetchStorage && Helper.config.disableMediaPreview) { 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-midi":
case "audio/x-mpeg": case "audio/x-mpeg":
case "audio/x-mpeg-3": case "audio/x-mpeg-3":
case "audio/flac":
case "audio/x-m4a":
if (!preview.link.startsWith("https://")) { if (!preview.link.startsWith("https://")) {
break; 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() { canProvideMessages() {
return this.isEnabled; return this.isEnabled;
} }
} }
module.exports = MessageStorage; 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/mpeg": "audio.mp3",
"audio/ogg": "audio.ogg", "audio/ogg": "audio.ogg",
"audio/vnd.wave": "audio.wav", "audio/vnd.wave": "audio.wav",
"audio/flac": "audio.flac", "audio/x-flac": "audio.flac",
"audio/x-m4a": "audio.m4a",
"image/bmp": "image.bmp", "image/bmp": "image.bmp",
"image/gif": "image.gif", "image/gif": "image.gif",
"image/jpeg": "image.jpg", "image/jpeg": "image.jpg",
"image/png": "image.png", "image/png": "image.png",
"image/webp": "image.webp", "image/webp": "image.webp",
"image/avif": "image.avif", "image/avif": "image.avif",
"image/jxl": "image.jxl",
"text/plain": "text.txt", "text/plain": "text.txt",
"video/mp4": "video.mp4", "video/mp4": "video.mp4",
"video/ogg": "video.ogv", "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") { 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"; detectedMimeType = "audio/wav";
} else if (detectedMimeType === "audio/x-flac") {
detectedMimeType = "audio/flac";
} }
res.setHeader("Content-Disposition", disposition); res.setHeader("Content-Disposition", disposition);

View file

@ -643,6 +643,12 @@ function initializeClient(socket, client, token, lastMessage, openChannel) {
const clientSettings = client.config.clientSettings; const clientSettings = client.config.clientSettings;
socket.emit("setting:all", 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) => { socket.on("sign-out", (tokenToSignOut) => {

View file

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

View file

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

View file

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

View file

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

View file

@ -91,7 +91,7 @@ describe("Network", function () {
rejectUnauthorized: false, rejectUnauthorized: false,
}); });
expect(network.validate()).to.be.true; 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.port).to.equal(6697);
expect(network.tls).to.be.true; expect(network.tls).to.be.true;
expect(network.rejectUnauthorized).to.be.true; expect(network.rejectUnauthorized).to.be.true;
@ -103,7 +103,7 @@ describe("Network", function () {
host: "some.fake.tld", host: "some.fake.tld",
}); });
expect(network2.validate()).to.be.true; 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; Helper.config.lockNetwork = false;
}); });
@ -265,7 +265,7 @@ describe("Network", function () {
// Lobby and initial channel // Lobby and initial channel
expect(network.channels.length).to.equal(2); expect(network.channels.length).to.equal(2);
const newChan = new Chan({name: "#freenode"}); const newChan = new Chan({name: "#foo"});
network.addChannel(newChan); network.addChannel(newChan);
expect(network.channels.length).to.equal(3); expect(network.channels.length).to.equal(3);
@ -278,13 +278,13 @@ describe("Network", function () {
const network = new Network({ const network = new Network({
channels: [chan1, chan2, chan3], channels: [chan1, chan2, chan3],
name: "freenode", name: "foo",
}); });
const newChan = new Chan({name: "#freenode"}); const newChan = new Chan({name: "#foo"});
network.addChannel(newChan); 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[1]).to.equal(chan1);
expect(network.channels[2]).to.equal(newChan); expect(network.channels[2]).to.equal(newChan);
expect(network.channels[3]).to.equal(chan2); expect(network.channels[3]).to.equal(chan2);
@ -299,7 +299,7 @@ describe("Network", function () {
channels: [chan1, chan2], channels: [chan1, chan2],
}); });
const newChan = new Chan({name: "#freenode"}); const newChan = new Chan({name: "#foo"});
network.addChannel(newChan); network.addChannel(newChan);
expect(network.channels[1]).to.equal(chan1); expect(network.channels[1]).to.equal(chan1);
@ -393,7 +393,7 @@ describe("Network", function () {
channels: [banlist, chan1, user1], channels: [banlist, chan1, user1],
}); });
const newChan = new Chan({name: "#freenode"}); const newChan = new Chan({name: "#foo"});
network.addChannel(newChan); network.addChannel(newChan);
expect(network.channels[1]).to.equal(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 () { it("should never add something in front of the lobby", function () {
const network = new Network({ const network = new Network({
name: "freenode", name: "foo",
channels: [], channels: [],
}); });

1992
yarn.lock

File diff suppressed because it is too large Load diff