mirror of
https://github.com/thelounge/thelounge.git
synced 2024-04-26 20:07:11 +02:00
Merge branch 'master' into maxleiter/improvedURLs
This commit is contained in:
commit
c9a1846231
|
@ -2,7 +2,7 @@
|
|||
root: true
|
||||
|
||||
parserOptions:
|
||||
ecmaVersion: 2018
|
||||
ecmaVersion: 2020
|
||||
|
||||
env:
|
||||
es6: true
|
||||
|
|
2
.github/ISSUE_TEMPLATE/Bug_Report.md
vendored
2
.github/ISSUE_TEMPLATE/Bug_Report.md
vendored
|
@ -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:_
|
||||
|
|
2
.github/ISSUE_TEMPLATE/Feature_Request.md
vendored
2
.github/ISSUE_TEMPLATE/Feature_Request.md
vendored
|
@ -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
|
||||
|
|
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
|
@ -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
2
.github/SUPPORT.md
vendored
|
@ -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.
|
||||
|
|
15
CHANGELOG.md
15
CHANGELOG.md
|
@ -4,6 +4,21 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
<!-- New entries go after this line -->
|
||||
|
||||
## v4.3.0-pre.2 - 2021-06-07 [Pre-release]
|
||||
|
||||
[See the full changelog](https://github.com/thelounge/thelounge/compare/v4.3.0-pre.1...v4.3.0-pre.2)
|
||||
|
||||
This is a pre-release for v4.3.0 to offer latest changes without having to wait for a stable release.
|
||||
At this stage, features may still be added or modified until the first release candidate for this version gets released.
|
||||
|
||||
Please refer to the commit list given above for a complete list of changes, or wait for the stable release to get a thoroughly prepared change log entry.
|
||||
|
||||
As with all pre-releases, this version requires explicit use of the `next` tag to be installed:
|
||||
|
||||
```sh
|
||||
yarn global add thelounge@next
|
||||
```
|
||||
|
||||
## v4.3.0-pre.1 - 2021-03-02 [Pre-release]
|
||||
|
||||
[See the full changelog](https://github.com/thelounge/thelounge/compare/v4.2.0...v4.3.0-pre.1)
|
||||
|
|
|
@ -20,8 +20,8 @@
|
|||
</p>
|
||||
<p align="center">
|
||||
<a href="https://demo.thelounge.chat/"><img
|
||||
alt="#thelounge IRC channel on freenode"
|
||||
src="https://img.shields.io/badge/freenode-%23thelounge-415364.svg?colorA=ff9e18"></a>
|
||||
alt="#thelounge IRC channel on Libera.Chat"
|
||||
src="https://img.shields.io/badge/Libera.Chat-%23thelounge-415364.svg?colorA=ff9e18"></a>
|
||||
<a href="https://yarn.pm/thelounge"><img
|
||||
alt="npm version"
|
||||
src="https://img.shields.io/npm/v/thelounge.svg?colorA=333a41&maxAge=3600"></a>
|
||||
|
@ -85,5 +85,5 @@ Before submitting any change, make sure to:
|
|||
|
||||
- Read the [Contributing instructions](https://github.com/thelounge/thelounge/blob/master/.github/CONTRIBUTING.md#contributing)
|
||||
- Run `yarn test` to execute linters and test suite
|
||||
- Run `yarn build` if you change or add anything in `client/js` or `client/views`
|
||||
- Run `yarn build` if you change or add anything in `client/js` or `client/components`
|
||||
- `yarn dev` can be used to start The Lounge with hot module reloading
|
||||
|
|
|
@ -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>.
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -39,6 +39,14 @@
|
|||
:network="network"
|
||||
:text="channel.topic"
|
||||
/></span>
|
||||
<MessageSearchForm
|
||||
v-if="
|
||||
$store.state.settings.searchEnabled &&
|
||||
['channel', 'query'].includes(channel.type)
|
||||
"
|
||||
:network="network"
|
||||
:channel="channel"
|
||||
/>
|
||||
<button
|
||||
class="mentions"
|
||||
aria-label="Open your mentions"
|
||||
|
@ -85,7 +93,12 @@
|
|||
>
|
||||
<div class="scroll-down-arrow" />
|
||||
</div>
|
||||
<MessageList ref="messageList" :network="network" :channel="channel" />
|
||||
<MessageList
|
||||
ref="messageList"
|
||||
:network="network"
|
||||
:channel="channel"
|
||||
:focused="focused"
|
||||
/>
|
||||
<ChatUserList v-if="channel.type === 'channel'" :channel="channel" />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -109,6 +122,7 @@ import MessageList from "./MessageList.vue";
|
|||
import ChatInput from "./ChatInput.vue";
|
||||
import ChatUserList from "./ChatUserList.vue";
|
||||
import SidebarToggle from "./SidebarToggle.vue";
|
||||
import MessageSearchForm from "./MessageSearchForm.vue";
|
||||
import ListBans from "./Special/ListBans.vue";
|
||||
import ListInvites from "./Special/ListInvites.vue";
|
||||
import ListChannels from "./Special/ListChannels.vue";
|
||||
|
@ -122,10 +136,12 @@ export default {
|
|||
ChatInput,
|
||||
ChatUserList,
|
||||
SidebarToggle,
|
||||
MessageSearchForm,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
channel: Object,
|
||||
focused: String,
|
||||
},
|
||||
computed: {
|
||||
specialComponent() {
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -17,6 +17,7 @@ export default {
|
|||
name: "DateMarker",
|
||||
props: {
|
||||
message: Object,
|
||||
focused: Boolean,
|
||||
},
|
||||
computed: {
|
||||
localeDate() {
|
||||
|
|
|
@ -3,7 +3,11 @@
|
|||
:id="'msg-' + message.id"
|
||||
:class="[
|
||||
'msg',
|
||||
{self: message.self, highlight: message.highlight, 'previous-source': isPreviousSource},
|
||||
{
|
||||
self: message.self,
|
||||
highlight: message.highlight || focused,
|
||||
'previous-source': isPreviousSource,
|
||||
},
|
||||
]"
|
||||
:data-type="message.type"
|
||||
:data-command="message.command"
|
||||
|
@ -25,9 +29,12 @@
|
|||
<template v-else-if="message.type === 'action'">
|
||||
<span class="from"><span class="only-copy">* </span></span>
|
||||
<span class="content" dir="auto">
|
||||
<Username :user="message.from" dir="auto" /> <ParsedMessage
|
||||
:message="message"
|
||||
/>
|
||||
<Username
|
||||
:user="message.from"
|
||||
:network="network"
|
||||
:channel="channel"
|
||||
dir="auto"
|
||||
/> <ParsedMessage :message="message" />
|
||||
<LinkPreview
|
||||
v-for="preview in message.previews"
|
||||
:key="preview.link"
|
||||
|
@ -41,7 +48,7 @@
|
|||
<span v-if="message.type === 'message'" class="from">
|
||||
<template v-if="message.from && message.from.nick">
|
||||
<span class="only-copy"><</span>
|
||||
<Username :user="message.from" />
|
||||
<Username :user="message.from" :network="network" :channel="channel" />
|
||||
<span class="only-copy">> </span>
|
||||
</template>
|
||||
</span>
|
||||
|
@ -55,7 +62,7 @@
|
|||
<span v-else class="from">
|
||||
<template v-if="message.from && message.from.nick">
|
||||
<span class="only-copy">-</span>
|
||||
<Username :user="message.from" />
|
||||
<Username :user="message.from" :network="network" :channel="channel" />
|
||||
<span class="only-copy">- </span>
|
||||
</template>
|
||||
</span>
|
||||
|
@ -107,6 +114,7 @@ export default {
|
|||
network: Object,
|
||||
keepScrollPosition: Function,
|
||||
isPreviousSource: Boolean,
|
||||
focused: Boolean,
|
||||
},
|
||||
computed: {
|
||||
timeFormat() {
|
||||
|
|
|
@ -30,6 +30,7 @@ export default {
|
|||
network: Object,
|
||||
messages: Array,
|
||||
keepScrollPosition: Function,
|
||||
focused: Boolean,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
|
@ -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() {
|
||||
|
|
153
client/components/MessageSearchForm.vue
Normal file
153
client/components/MessageSearchForm.vue
Normal 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>
|
|
@ -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: {
|
||||
|
|
|
@ -1,23 +1,25 @@
|
|||
<template>
|
||||
<span class="content">
|
||||
<Username :user="message.from" />
|
||||
<i class="hostmask"> ({{ message.hostmask }})</i>
|
||||
<template v-if="message.account !== false">
|
||||
<i class="hostmask"> (<ParsedMessage :network="network" :text="message.hostmask" />)</i>
|
||||
<template v-if="message.account">
|
||||
<i class="account"> [{{ message.account }}]</i>
|
||||
</template>
|
||||
<template v-if="message.gecos !== false">
|
||||
<i class="realname"> {{ message.gecos }} -</i>
|
||||
<template v-if="message.gecos">
|
||||
<i class="realname"> {{ message.gecos }}</i>
|
||||
</template>
|
||||
has joined the channel
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
name: "MessageTypeJoin",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
|
|
|
@ -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
|
||||
>
|
||||
|
|
|
@ -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
|
||||
>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -168,10 +168,11 @@
|
|||
/>
|
||||
</div>
|
||||
<div class="connect-row">
|
||||
<label for="connect:leaveMessage">Away message</label>
|
||||
<label for="connect:leaveMessage">Leave message</label>
|
||||
<input
|
||||
id="connect:leaveMessage"
|
||||
v-model="defaults.leaveMessage"
|
||||
autocomplete="off"
|
||||
class="input"
|
||||
name="leaveMessage"
|
||||
placeholder="The Lounge - https://thelounge.chat"
|
||||
|
@ -193,6 +194,7 @@ the server tab on new connection"
|
|||
<textarea
|
||||
id="connect:commands"
|
||||
ref="commandsInput"
|
||||
autocomplete="off"
|
||||
:value="defaults.commands ? defaults.commands.join('\n') : ''"
|
||||
class="input"
|
||||
name="commands"
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
|
|
246
client/components/Windows/SearchResults.vue
Normal file
246
client/components/Windows/SearchResults.vue
Normal 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>
|
|
@ -3,7 +3,13 @@
|
|||
<div class="header">
|
||||
<SidebarToggle />
|
||||
</div>
|
||||
<form ref="settingsForm" class="container" @change="onChange" @submit.prevent>
|
||||
<form
|
||||
ref="settingsForm"
|
||||
class="container"
|
||||
autocomplete="off"
|
||||
@change="onChange"
|
||||
@submit.prevent
|
||||
>
|
||||
<h1 class="title">Settings</h1>
|
||||
|
||||
<div>
|
||||
|
@ -172,8 +178,14 @@
|
|||
</div>
|
||||
<div v-if="$store.state.settings.advanced">
|
||||
<label class="opt">
|
||||
<label for="nickPostfix" class="sr-only">
|
||||
Nick autocomplete postfix (for example a comma)
|
||||
<label for="nickPostfix" class="opt">
|
||||
Nick autocomplete postfix
|
||||
<span
|
||||
class="tooltipped tooltipped-n tooltipped-no-delay"
|
||||
aria-label="Nick autocomplete postfix (for example a comma)"
|
||||
>
|
||||
<button class="extra-help" />
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="nickPostfix"
|
||||
|
@ -236,18 +248,11 @@
|
|||
<div>
|
||||
<label class="opt">
|
||||
<input
|
||||
:checked="$store.state.settings.uploadCanvas"
|
||||
:checked="$store.state.settings.removeImageMetadata"
|
||||
type="checkbox"
|
||||
name="uploadCanvas"
|
||||
name="removeImageMetadata"
|
||||
/>
|
||||
Attempt to remove metadata from images before uploading
|
||||
<span
|
||||
class="tooltipped tooltipped-n tooltipped-no-delay"
|
||||
aria-label="This option renders the image into a canvas element to remove metadata from the image.
|
||||
This may break orientation if your browser does not support that."
|
||||
>
|
||||
<button class="extra-help" />
|
||||
</span>
|
||||
Remove metadata from uploaded images
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -353,7 +358,7 @@ This may break orientation if your browser does not support that."
|
|||
Custom highlights
|
||||
<span
|
||||
class="tooltipped tooltipped-n tooltipped-no-delay"
|
||||
aria-label="If a message contains any of these comma-separated
|
||||
aria-label="If a message contains any of these comma-separated
|
||||
expressions, it will trigger a highlight."
|
||||
>
|
||||
<button class="extra-help" />
|
||||
|
@ -376,8 +381,8 @@ expressions, it will trigger a highlight."
|
|||
Highlight exceptions
|
||||
<span
|
||||
class="tooltipped tooltipped-n tooltipped-no-delay"
|
||||
aria-label="If a message contains any of these comma-separated
|
||||
expressions, it will not trigger a highlight even if it contains
|
||||
aria-label="If a message contains any of these comma-separated
|
||||
expressions, it will not trigger a highlight even if it contains
|
||||
your nickname or expressions defined in custom highlights."
|
||||
>
|
||||
<button class="extra-help" />
|
||||
|
@ -405,10 +410,11 @@ your nickname or expressions defined in custom highlights."
|
|||
>
|
||||
<h2 id="label-change-password">Change password</h2>
|
||||
<div class="password-container">
|
||||
<label for="old_password_input" class="sr-only"> Enter current password </label>
|
||||
<label for="current-password" class="sr-only"> Enter current password </label>
|
||||
<RevealPassword v-slot:default="slotProps">
|
||||
<input
|
||||
id="old_password_input"
|
||||
id="current-password"
|
||||
autocomplete="current-password"
|
||||
:type="slotProps.isVisible ? 'text' : 'password'"
|
||||
name="old_password"
|
||||
class="input"
|
||||
|
@ -417,26 +423,26 @@ your nickname or expressions defined in custom highlights."
|
|||
</RevealPassword>
|
||||
</div>
|
||||
<div class="password-container">
|
||||
<label for="new_password_input" class="sr-only">
|
||||
Enter desired new password
|
||||
</label>
|
||||
<label for="new-password" class="sr-only"> Enter desired new password </label>
|
||||
<RevealPassword v-slot:default="slotProps">
|
||||
<input
|
||||
id="new_password_input"
|
||||
id="new-password"
|
||||
:type="slotProps.isVisible ? 'text' : 'password'"
|
||||
name="new_password"
|
||||
autocomplete="new-password"
|
||||
class="input"
|
||||
placeholder="Enter desired new password"
|
||||
/>
|
||||
</RevealPassword>
|
||||
</div>
|
||||
<div class="password-container">
|
||||
<label for="verify_password_input" class="sr-only"> Repeat new password </label>
|
||||
<label for="new-password-verify" class="sr-only"> Repeat new password </label>
|
||||
<RevealPassword v-slot:default="slotProps">
|
||||
<input
|
||||
id="verify_password_input"
|
||||
id="new-password-verify"
|
||||
:type="slotProps.isVisible ? 'text' : 'password'"
|
||||
name="verify_password"
|
||||
autocomplete="new-password"
|
||||
class="input"
|
||||
placeholder="Repeat new password"
|
||||
/>
|
||||
|
|
|
@ -284,7 +284,9 @@ p {
|
|||
#viewport .lt::before,
|
||||
#viewport .rt::before,
|
||||
#chat button.mentions::before,
|
||||
#chat button.close::before,
|
||||
#chat button.menu::before,
|
||||
#chat button.search::before,
|
||||
.channel-list-item::before,
|
||||
#footer .icon,
|
||||
#chat .count::before,
|
||||
|
@ -342,6 +344,8 @@ p {
|
|||
#viewport .rt::before { content: "\f0c0"; /* https://fontawesome.com/icons/users?style=solid */ }
|
||||
#chat button.menu::before { content: "\f142"; /* http://fontawesome.io/icon/ellipsis-v/ */ }
|
||||
#chat button.mentions::before { content: "\f1fa"; /* https://fontawesome.com/icons/at?style=solid */ }
|
||||
#chat button.search::before { content: "\f002"; /* https://fontawesome.com/icons/search?style=solid */ }
|
||||
#chat button.close::before { content: "\f00d"; /* https://fontawesome.com/icons/times?style=solid */ }
|
||||
|
||||
.context-menu-join::before { content: "\f067"; /* http://fontawesome.io/icon/plus/ */ }
|
||||
.context-menu-user::before { content: "\f007"; /* http://fontawesome.io/icon/user/ */ }
|
||||
|
@ -352,8 +356,8 @@ p {
|
|||
.context-menu-action-whois::before { content: "\f05a"; /* http://fontawesome.io/icon/info-circle/ */ }
|
||||
.context-menu-action-ignore::before { content: "\f506"; /* https://fontawesome.com/icons/user-slash?style=solid */ }
|
||||
.context-menu-action-kick::before { content: "\f05e"; /* http://fontawesome.io/icon/ban/ */ }
|
||||
.context-menu-action-op::before { content: "\f1fa"; /* http://fontawesome.io/icon/at/ */ }
|
||||
.context-menu-action-voice::before { content: "\f067"; /* http://fontawesome.io/icon/plus/ */ }
|
||||
.context-menu-action-set-mode::before { content: "\f067"; /* http://fontawesome.io/icon/plus/ */ }
|
||||
.context-menu-action-revoke-mode::before { content: "\f068"; /* http://fontawesome.io/icon/minus/ */ }
|
||||
.context-menu-network::before { content: "\f233"; /* https://fontawesome.com/icons/server?style=solid */ }
|
||||
.context-menu-edit::before { content: "\f303"; /* https://fontawesome.com/icons/pencil-alt?style=solid */ }
|
||||
.context-menu-clear-history::before { content: "\f1f8"; /* https://fontawesome.com/icons/trash?style=solid */ }
|
||||
|
@ -575,7 +579,9 @@ p {
|
|||
#viewport .lt,
|
||||
#viewport .rt,
|
||||
#chat button.mentions,
|
||||
#chat button.menu {
|
||||
#chat button.search,
|
||||
#chat button.menu,
|
||||
#chat button.close {
|
||||
color: #607992;
|
||||
display: flex;
|
||||
font-size: 14px;
|
||||
|
@ -589,7 +595,9 @@ p {
|
|||
#viewport .lt::before,
|
||||
#viewport .rt::before,
|
||||
#chat button.mentions::before,
|
||||
#chat button.menu::before {
|
||||
#chat button.search::before,
|
||||
#chat button.menu::before,
|
||||
#chat button.close::before {
|
||||
width: 36px;
|
||||
line-height: 36px; /* Fix alignment in Microsoft Edge */
|
||||
}
|
||||
|
@ -2848,3 +2856,10 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
|||
#chat table.channel-list .topic {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.chat-view[data-type="search-results"] .search-status {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
|
20
client/js/commands/search.js
Normal file
20
client/js/commands/search.js
Normal 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};
|
|
@ -184,11 +184,12 @@ export function generateChannelContextMenu($root, channel, network) {
|
|||
}
|
||||
|
||||
export function generateUserContextMenu($root, channel, network, user) {
|
||||
const currentChannelUser = channel.users.find((u) => u.nick === network.nick) || {};
|
||||
const currentChannelModes = currentChannelUser.modes || [];
|
||||
const currentChannelUser = channel
|
||||
? channel.users.find((u) => u.nick === network.nick) || {}
|
||||
: {};
|
||||
|
||||
const whois = () => {
|
||||
const chan = $root.$store.getters.findChannelOnCurrentNetwork(user.nick);
|
||||
const chan = network.channels.find((c) => c.name === user.nick);
|
||||
|
||||
if (chan) {
|
||||
$root.switchToChannel(network, chan);
|
||||
|
@ -246,66 +247,93 @@ export function generateUserContextMenu($root, channel, network, user) {
|
|||
},
|
||||
];
|
||||
|
||||
if (currentChannelModes.includes("@")) {
|
||||
items.push({
|
||||
label: "Kick",
|
||||
type: "item",
|
||||
class: "action-kick",
|
||||
action() {
|
||||
socket.emit("input", {
|
||||
target: channel.id,
|
||||
text: "/kick " + user.nick,
|
||||
});
|
||||
},
|
||||
});
|
||||
// Bail because we're in a query or we don't have a special mode.
|
||||
if (!currentChannelUser.modes || currentChannelUser.modes.length < 1) {
|
||||
return items;
|
||||
}
|
||||
|
||||
if (user.modes.includes("@")) {
|
||||
// Names of the modes we are able to change
|
||||
const modes = {
|
||||
"~": ["owner", "q"],
|
||||
"&": ["admin", "a"],
|
||||
"@": ["operator", "o"],
|
||||
"%": ["half-op", "h"],
|
||||
"+": ["voice", "v"],
|
||||
};
|
||||
|
||||
// Labels for the mode changes. For example .rev(['admin', 'a']) => 'Revoke admin (-a)'
|
||||
const modeTextTemplate = {
|
||||
revoke: (m) => `Revoke ${m[0]} (-${m[1]})`,
|
||||
give: (m) => `Give ${m[0]} (+${m[1]})`,
|
||||
};
|
||||
|
||||
const networkModes = network.serverOptions.PREFIX;
|
||||
|
||||
/**
|
||||
* Determine whether the prefix of mode p1 has access to perform actions on p2.
|
||||
*
|
||||
* EXAMPLE:
|
||||
* compare('@', '@') => true
|
||||
* compare('&', '@') => true
|
||||
* compare('+', '~') => false
|
||||
* @param {string} p1 The mode performing an action
|
||||
* @param {string} p2 The target mode
|
||||
*
|
||||
* @return {boolean} whether p1 can perform an action on p2
|
||||
*/
|
||||
function compare(p1, p2) {
|
||||
// The modes ~ and @ can perform actions on their own mode. The others on modes below.
|
||||
return "~@".indexOf(p1) > -1
|
||||
? networkModes.indexOf(p1) <= networkModes.indexOf(p2)
|
||||
: networkModes.indexOf(p1) < networkModes.indexOf(p2);
|
||||
}
|
||||
|
||||
networkModes.forEach((prefix) => {
|
||||
if (!compare(currentChannelUser.modes[0], prefix)) {
|
||||
// Our highest mode is below the current mode. Bail.
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user.modes.includes(prefix)) {
|
||||
// The target doesn't already have this mode, therefore we can set it.
|
||||
items.push({
|
||||
label: "Revoke operator (-o)",
|
||||
label: modeTextTemplate.give(modes[prefix]),
|
||||
type: "item",
|
||||
class: "action-op",
|
||||
class: "action-set-mode",
|
||||
action() {
|
||||
socket.emit("input", {
|
||||
target: channel.id,
|
||||
text: "/deop " + user.nick,
|
||||
text: "/mode +" + modes[prefix][1] + " " + user.nick,
|
||||
});
|
||||
},
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
label: "Give operator (+o)",
|
||||
label: modeTextTemplate.revoke(modes[prefix]),
|
||||
type: "item",
|
||||
class: "action-op",
|
||||
class: "action-revoke-mode",
|
||||
action() {
|
||||
socket.emit("input", {
|
||||
target: channel.id,
|
||||
text: "/op " + user.nick,
|
||||
text: "/mode -" + modes[prefix][1] + " " + user.nick,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (user.modes.includes("+")) {
|
||||
// Determine if we are half-op or op depending on the network modes so we can kick.
|
||||
if (!compare(networkModes.indexOf("%") > -1 ? "%" : "@", currentChannelUser.modes[0])) {
|
||||
if (user.modes.length === 0 || compare(currentChannelUser.modes[0], user.modes[0])) {
|
||||
// Check if the target user has no mode or a mode lower than ours.
|
||||
items.push({
|
||||
label: "Revoke voice (-v)",
|
||||
label: "Kick",
|
||||
type: "item",
|
||||
class: "action-voice",
|
||||
class: "action-kick",
|
||||
action() {
|
||||
socket.emit("input", {
|
||||
target: channel.id,
|
||||
text: "/devoice " + user.nick,
|
||||
});
|
||||
},
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
label: "Give voice (+v)",
|
||||
type: "item",
|
||||
class: "action-voice",
|
||||
action() {
|
||||
socket.emit("input", {
|
||||
target: channel.id,
|
||||
text: "/voice " + user.nick,
|
||||
text: "/kick " + user.nick,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -13,6 +13,7 @@ import Settings from "../components/Windows/Settings.vue";
|
|||
import Help from "../components/Windows/Help.vue";
|
||||
import Changelog from "../components/Windows/Changelog.vue";
|
||||
import NetworkEdit from "../components/Windows/NetworkEdit.vue";
|
||||
import SearchResults from "../components/Windows/SearchResults.vue";
|
||||
import RoutedChat from "../components/RoutedChat.vue";
|
||||
import store from "./store";
|
||||
|
||||
|
@ -63,6 +64,11 @@ const router = new VueRouter({
|
|||
path: "/:networkHost/:channelName?",
|
||||
component: RoutedChat,
|
||||
},
|
||||
{
|
||||
name: "SearchResults",
|
||||
path: "/chan-:id/search",
|
||||
component: SearchResults,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
|
|
@ -98,7 +98,7 @@ export const config = normalizeConfig({
|
|||
media: {
|
||||
default: true,
|
||||
},
|
||||
uploadCanvas: {
|
||||
removeImageMetadata: {
|
||||
default: true,
|
||||
},
|
||||
userStyles: {
|
||||
|
@ -109,6 +109,9 @@ export const config = normalizeConfig({
|
|||
}
|
||||
},
|
||||
},
|
||||
searchEnabled: {
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
export function createState() {
|
||||
|
|
|
@ -25,3 +25,4 @@ import "./changelog";
|
|||
import "./setting";
|
||||
import "./history_clear";
|
||||
import "./mentions";
|
||||
import "./search";
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
|
|
13
client/js/socket-events/search.js
Normal file
13
client/js/socket-events/search.js
Normal 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);
|
||||
});
|
|
@ -86,6 +86,15 @@ function loadFromLocalStorage() {
|
|||
storedSettings.highlights = storedSettings.highlights.join(", ");
|
||||
}
|
||||
|
||||
// Convert deprecated uploadCanvas to removeImageMetadata
|
||||
if (
|
||||
storedSettings.uploadCanvas !== undefined &&
|
||||
storedSettings.removeImageMetadata === undefined
|
||||
) {
|
||||
storedSettings.removeImageMetadata = storedSettings.uploadCanvas;
|
||||
delete storedSettings.uploadCanvas;
|
||||
}
|
||||
|
||||
return storedSettings;
|
||||
}
|
||||
|
||||
|
|
|
@ -38,6 +38,9 @@ const store = new Vuex.Store({
|
|||
versionStatus: "loading",
|
||||
versionDataExpired: false,
|
||||
serverHasSettings: false,
|
||||
messageSearchResults: null,
|
||||
messageSearchInProgress: false,
|
||||
searchEnabled: false,
|
||||
},
|
||||
mutations: {
|
||||
appLoaded(state) {
|
||||
|
@ -112,12 +115,39 @@ const store = new Vuex.Store({
|
|||
serverHasSettings(state, value) {
|
||||
state.serverHasSettings = value;
|
||||
},
|
||||
messageSearchInProgress(state, value) {
|
||||
state.messageSearchInProgress = value;
|
||||
},
|
||||
messageSearchResults(state, value) {
|
||||
state.messageSearchResults = value;
|
||||
},
|
||||
addMessageSearchResults(state, value) {
|
||||
// Append the search results and add networks and channels to new messages
|
||||
value.results = [...state.messageSearchResults.results, ...value.results];
|
||||
|
||||
state.messageSearchResults = value;
|
||||
},
|
||||
},
|
||||
getters: {
|
||||
findChannelOnCurrentNetwork: (state) => (name) => {
|
||||
name = name.toLowerCase();
|
||||
return state.activeChannel.network.channels.find((c) => c.name.toLowerCase() === name);
|
||||
},
|
||||
findChannelOnNetwork: (state) => (networkUuid, channelName) => {
|
||||
for (const network of state.networks) {
|
||||
if (network.uuid !== networkUuid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const channel of network.channels) {
|
||||
if (channel.name === channelName) {
|
||||
return {network, channel};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
findChannel: (state) => (id) => {
|
||||
for (const network of state.networks) {
|
||||
for (const channel of network.channels) {
|
||||
|
@ -177,7 +207,14 @@ const store = new Vuex.Store({
|
|||
// TODO: This should be a mutation
|
||||
channel.pendingMessage = "";
|
||||
channel.inputHistoryPosition = 0;
|
||||
channel.inputHistory = [""];
|
||||
|
||||
channel.inputHistory = [""].concat(
|
||||
channel.messages
|
||||
.filter((m) => m.self && m.text && m.type === "message")
|
||||
.map((m) => m.text)
|
||||
.reverse()
|
||||
.slice(null, 99)
|
||||
);
|
||||
channel.historyLoading = false;
|
||||
channel.scrolledToBottom = true;
|
||||
channel.editTopic = false;
|
||||
|
|
|
@ -26,31 +26,36 @@ class Uploader {
|
|||
}
|
||||
|
||||
dragOver(event) {
|
||||
// Prevent dragover event completely and do nothing with it
|
||||
// This stops the browser from trying to guess which cursor to show
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer.types.includes("Files")) {
|
||||
// Prevent dragover event completely and do nothing with it
|
||||
// This stops the browser from trying to guess which cursor to show
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
dragEnter(event) {
|
||||
event.preventDefault();
|
||||
|
||||
// relatedTarget is the target where we entered the drag from
|
||||
// when dragging from another window, the target is null, otherwise its a DOM element
|
||||
if (!event.relatedTarget && event.dataTransfer.types.includes("Files")) {
|
||||
event.preventDefault();
|
||||
|
||||
this.overlay.classList.add("is-dragover");
|
||||
}
|
||||
}
|
||||
|
||||
dragLeave(event) {
|
||||
event.preventDefault();
|
||||
|
||||
// If relatedTarget is null, that means we are no longer dragging over the page
|
||||
if (!event.relatedTarget) {
|
||||
event.preventDefault();
|
||||
this.overlay.classList.remove("is-dragover");
|
||||
}
|
||||
}
|
||||
|
||||
drop(event) {
|
||||
if (!event.dataTransfer.types.includes("Files")) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
this.overlay.classList.remove("is-dragover");
|
||||
|
||||
|
@ -137,46 +142,7 @@ class Uploader {
|
|||
// This issue only happens if The Lounge is proxied through other software
|
||||
// as it may buffer the upload before the upload request will be processed by The Lounge.
|
||||
this.tokenKeepAlive = setInterval(() => socket.emit("upload:ping", token), 40 * 1000);
|
||||
|
||||
if (
|
||||
store.state.settings.uploadCanvas &&
|
||||
file.type.startsWith("image/") &&
|
||||
!file.type.includes("svg") &&
|
||||
file.type !== "image/gif"
|
||||
) {
|
||||
this.renderImage(file, (newFile) => this.performUpload(token, newFile));
|
||||
} else {
|
||||
this.performUpload(token, file);
|
||||
}
|
||||
}
|
||||
|
||||
renderImage(file, callback) {
|
||||
const fileReader = new FileReader();
|
||||
|
||||
fileReader.onabort = () => callback(file);
|
||||
fileReader.onerror = () => fileReader.abort();
|
||||
|
||||
fileReader.onload = () => {
|
||||
const img = new Image();
|
||||
|
||||
img.onerror = () => callback(file);
|
||||
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
callback(new File([blob], file.name));
|
||||
}, file.type);
|
||||
};
|
||||
|
||||
img.src = fileReader.result;
|
||||
};
|
||||
|
||||
fileReader.readAsDataURL(file);
|
||||
this.performUpload(token, file);
|
||||
}
|
||||
|
||||
performUpload(token, file) {
|
||||
|
@ -219,6 +185,7 @@ class Uploader {
|
|||
};
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("removeMetadata", store.state.settings.removeImageMetadata);
|
||||
formData.append("file", file);
|
||||
this.xhr.open("POST", `uploads/new/${token}`);
|
||||
this.xhr.send(formData);
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
28
package.json
28
package.json
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "thelounge",
|
||||
"description": "The self-hosted Web IRC client",
|
||||
"version": "4.3.0-pre.1",
|
||||
"version": "4.3.0-pre.2",
|
||||
"preferGlobal": true,
|
||||
"bin": {
|
||||
"thelounge": "index.js"
|
||||
|
@ -42,9 +42,10 @@
|
|||
"dependencies": {
|
||||
"bcryptjs": "2.4.3",
|
||||
"busboy": "0.3.1",
|
||||
"chalk": "4.1.0",
|
||||
"cheerio": "1.0.0-rc.5",
|
||||
"commander": "7.1.0",
|
||||
"chalk": "4.1.1",
|
||||
"cheerio": "1.0.0-rc.9",
|
||||
"commander": "7.2.0",
|
||||
"content-disposition": "0.5.3",
|
||||
"express": "4.17.1",
|
||||
"file-type": "16.2.0",
|
||||
"filenamify": "4.2.0",
|
||||
|
@ -60,9 +61,10 @@
|
|||
"read": "1.0.7",
|
||||
"read-chunk": "3.2.0",
|
||||
"semver": "7.3.4",
|
||||
"sharp": "0.28.0",
|
||||
"socket.io": "3.1.2",
|
||||
"tlds": "1.216.0",
|
||||
"ua-parser-js": "0.7.23",
|
||||
"ua-parser-js": "0.7.24",
|
||||
"uuid": "8.3.2",
|
||||
"web-push": "3.4.4",
|
||||
"yarn": "1.22.10"
|
||||
|
@ -71,20 +73,20 @@
|
|||
"sqlite3": "5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.13.8",
|
||||
"@babel/preset-env": "7.13.9",
|
||||
"@fortawesome/fontawesome-free": "5.15.2",
|
||||
"@babel/core": "7.14.3",
|
||||
"@babel/preset-env": "7.14.4",
|
||||
"@fortawesome/fontawesome-free": "5.15.3",
|
||||
"@vue/server-test-utils": "1.1.3",
|
||||
"@vue/test-utils": "1.1.3",
|
||||
"babel-loader": "8.2.2",
|
||||
"babel-plugin-istanbul": "6.0.0",
|
||||
"chai": "4.3.0",
|
||||
"chai": "4.3.4",
|
||||
"copy-webpack-plugin": "7.0.0",
|
||||
"css-loader": "5.1.1",
|
||||
"cssnano": "4.1.10",
|
||||
"dayjs": "1.10.4",
|
||||
"cssnano": "4.1.11",
|
||||
"dayjs": "1.10.5",
|
||||
"emoji-regex": "9.2.1",
|
||||
"eslint": "7.21.0",
|
||||
"eslint": "7.23.0",
|
||||
"eslint-config-prettier": "6.15.0",
|
||||
"eslint-plugin-vue": "7.5.0",
|
||||
"fuzzy": "0.1.3",
|
||||
|
@ -95,7 +97,7 @@
|
|||
"normalize.css": "8.0.1",
|
||||
"npm-run-all": "4.1.5",
|
||||
"nyc": "15.1.0",
|
||||
"postcss": "8.2.5",
|
||||
"postcss": "8.2.10",
|
||||
"postcss-import": "14.0.0",
|
||||
"postcss-loader": "5.0.0",
|
||||
"postcss-preset-env": "6.7.0",
|
||||
|
|
|
@ -63,6 +63,7 @@ function Client(manager, name, config = {}) {
|
|||
messageStorage: [],
|
||||
highlightRegex: null,
|
||||
highlightExceptionRegex: null,
|
||||
messageProvider: undefined,
|
||||
});
|
||||
|
||||
const client = this;
|
||||
|
@ -72,7 +73,8 @@ function Client(manager, name, config = {}) {
|
|||
|
||||
if (!Helper.config.public && client.config.log) {
|
||||
if (Helper.config.messageStorage.includes("sqlite")) {
|
||||
client.messageStorage.push(new MessageStorage(client));
|
||||
client.messageProvider = new MessageStorage(client);
|
||||
client.messageStorage.push(client.messageProvider);
|
||||
}
|
||||
|
||||
if (Helper.config.messageStorage.includes("text")) {
|
||||
|
@ -102,10 +104,17 @@ function Client(manager, name, config = {}) {
|
|||
delete client.config.awayMessage;
|
||||
}
|
||||
|
||||
if (client.config.uploadCanvas) {
|
||||
client.config.clientSettings.removeImageMetadata = client.config.uploadCanvas;
|
||||
delete client.config.uploadCanvas;
|
||||
}
|
||||
|
||||
if (client.config.clientSettings.awayMessage) {
|
||||
client.awayMessage = client.config.clientSettings.awayMessage;
|
||||
}
|
||||
|
||||
client.config.clientSettings.searchEnabled = client.messageProvider !== undefined;
|
||||
|
||||
client.compileCustomHighlights();
|
||||
|
||||
_.forOwn(client.config.sessions, (session) => {
|
||||
|
@ -534,6 +543,14 @@ Client.prototype.clearHistory = function (data) {
|
|||
}
|
||||
};
|
||||
|
||||
Client.prototype.search = function (query) {
|
||||
if (this.messageProvider === undefined) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
return this.messageProvider.search(query);
|
||||
};
|
||||
|
||||
Client.prototype.open = function (socketId, target) {
|
||||
// Due to how socket.io works internally, normal events may arrive later than
|
||||
// the disconnect event, and because we can't control this timing precisely,
|
||||
|
|
|
@ -34,7 +34,7 @@ try {
|
|||
createPackagesFolder();
|
||||
|
||||
// Merge config key-values passed as CLI options into the main config
|
||||
Helper.mergeConfig(Helper.config, program.config);
|
||||
Helper.mergeConfig(Helper.config, program.opts().config);
|
||||
|
||||
require("./start");
|
||||
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -190,7 +190,7 @@ Network.prototype.createIrcFramework = function (client) {
|
|||
|
||||
// Request only new messages from ZNC if we have sqlite logging enabled
|
||||
// See http://wiki.znc.in/Playback
|
||||
if (client.config.log && client.messageStorage.find((s) => s.canProvideMessages())) {
|
||||
if (client.messageProvider) {
|
||||
this.irc.requestCap("znc.in/playback");
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
const clientSideCommands = ["/collapse", "/expand"];
|
||||
const clientSideCommands = ["/collapse", "/expand", "/search"];
|
||||
|
||||
const passThroughCommands = [
|
||||
"/as",
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -51,8 +51,13 @@ module.exports = function (irc, network) {
|
|||
return Helper.compareHostmask(entry, data);
|
||||
});
|
||||
|
||||
// Server messages go to server window, no questions asked
|
||||
if (data.from_server) {
|
||||
// Server messages that aren't targeted at a channel go to the server window
|
||||
if (
|
||||
data.from_server &&
|
||||
(!data.target ||
|
||||
!network.getChannel(data.target) ||
|
||||
network.getChannel(data.target).type !== Chan.Type.CHANNEL)
|
||||
) {
|
||||
chan = network.channels[0];
|
||||
from = chan.getUser(data.nick);
|
||||
} else {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -10,26 +10,31 @@ const readChunk = require("read-chunk");
|
|||
const crypto = require("crypto");
|
||||
const isUtf8 = require("is-utf8");
|
||||
const log = require("../log");
|
||||
const contentDisposition = require("content-disposition");
|
||||
const sharp = require("sharp");
|
||||
|
||||
// List of allowed mime types that can be rendered in browser
|
||||
// without forcing it to be downloaded
|
||||
const inlineContentDispositionTypes = [
|
||||
"application/ogg",
|
||||
"audio/midi",
|
||||
"audio/mpeg",
|
||||
"audio/ogg",
|
||||
"audio/vnd.wave",
|
||||
"image/bmp",
|
||||
"image/gif",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/avif",
|
||||
"text/plain",
|
||||
"video/mp4",
|
||||
"video/ogg",
|
||||
"video/webm",
|
||||
];
|
||||
// Map of allowed mime types to their respecive default filenames
|
||||
// that will be rendered in browser without forcing them to be downloaded
|
||||
const inlineContentDispositionTypes = {
|
||||
"application/ogg": "media.ogx",
|
||||
"audio/midi": "audio.midi",
|
||||
"audio/mpeg": "audio.mp3",
|
||||
"audio/ogg": "audio.ogg",
|
||||
"audio/vnd.wave": "audio.wav",
|
||||
"audio/x-flac": "audio.flac",
|
||||
"audio/x-m4a": "audio.m4a",
|
||||
"image/bmp": "image.bmp",
|
||||
"image/gif": "image.gif",
|
||||
"image/jpeg": "image.jpg",
|
||||
"image/png": "image.png",
|
||||
"image/webp": "image.webp",
|
||||
"image/avif": "image.avif",
|
||||
"image/jxl": "image.jxl",
|
||||
"text/plain": "text.txt",
|
||||
"video/mp4": "video.mp4",
|
||||
"video/ogg": "video.ogv",
|
||||
"video/webm": "video.webm",
|
||||
};
|
||||
|
||||
const uploadTokens = new Map();
|
||||
|
||||
|
@ -92,17 +97,30 @@ class Uploader {
|
|||
}
|
||||
|
||||
// Force a download in the browser if it's not an allowed type (binary or otherwise unknown)
|
||||
const contentDisposition = inlineContentDispositionTypes.includes(detectedMimeType)
|
||||
? "inline"
|
||||
: "attachment";
|
||||
let slug = req.params.slug;
|
||||
const isInline = detectedMimeType in inlineContentDispositionTypes;
|
||||
let disposition = isInline ? "inline" : "attachment";
|
||||
|
||||
if (detectedMimeType === "audio/vnd.wave") {
|
||||
// Send a more common mime type for wave audio files
|
||||
// so that browsers can play them correctly
|
||||
detectedMimeType = "audio/wav";
|
||||
if (!slug && isInline) {
|
||||
slug = inlineContentDispositionTypes[detectedMimeType];
|
||||
}
|
||||
|
||||
res.setHeader("Content-Disposition", contentDisposition);
|
||||
if (slug) {
|
||||
disposition = contentDisposition(slug.trim(), {
|
||||
fallback: false,
|
||||
type: disposition,
|
||||
});
|
||||
}
|
||||
|
||||
// Send a more common mime type for audio files
|
||||
// so that browsers can play them correctly
|
||||
if (detectedMimeType === "audio/vnd.wave") {
|
||||
detectedMimeType = "audio/wav";
|
||||
} else if (detectedMimeType === "audio/x-flac") {
|
||||
detectedMimeType = "audio/flac";
|
||||
}
|
||||
|
||||
res.setHeader("Content-Disposition", disposition);
|
||||
res.setHeader("Cache-Control", "max-age=86400");
|
||||
res.contentType(detectedMimeType);
|
||||
|
||||
|
@ -116,6 +134,7 @@ class Uploader {
|
|||
let destDir;
|
||||
let destPath;
|
||||
let streamWriter;
|
||||
let removeMetadata;
|
||||
|
||||
const doneCallback = () => {
|
||||
// detach the stream and drain any remaining data
|
||||
|
@ -134,6 +153,19 @@ class Uploader {
|
|||
}
|
||||
};
|
||||
|
||||
const successfullCompletion = () => {
|
||||
doneCallback();
|
||||
|
||||
if (!uploadUrl) {
|
||||
return res.status(400).json({error: "Missing file"});
|
||||
}
|
||||
|
||||
// upload was done, send the generated file url to the client
|
||||
res.status(200).json({
|
||||
url: uploadUrl,
|
||||
});
|
||||
};
|
||||
|
||||
const abortWithError = (err) => {
|
||||
doneCallback();
|
||||
|
||||
|
@ -180,6 +212,11 @@ class Uploader {
|
|||
busboyInstance.on("partsLimit", () => abortWithError(Error("Parts limit reached")));
|
||||
busboyInstance.on("filesLimit", () => abortWithError(Error("Files limit reached")));
|
||||
busboyInstance.on("fieldsLimit", () => abortWithError(Error("Fields limit reached")));
|
||||
busboyInstance.on("field", (fieldname, val) => {
|
||||
if (fieldname === "removeMetadata") {
|
||||
removeMetadata = val === "true";
|
||||
}
|
||||
});
|
||||
|
||||
// generate a random output filename for the file
|
||||
// we use do/while loop to prevent the rare case of generating a file name
|
||||
|
@ -200,11 +237,7 @@ class Uploader {
|
|||
return abortWithError(err);
|
||||
}
|
||||
|
||||
// Open a file stream for writing
|
||||
streamWriter = fs.createWriteStream(destPath);
|
||||
streamWriter.on("error", abortWithError);
|
||||
|
||||
busboyInstance.on("file", (fieldname, fileStream, filename) => {
|
||||
busboyInstance.on("file", (fieldname, fileStream, filename, encoding, contentType) => {
|
||||
uploadUrl = `${randomName}/${encodeURIComponent(filename)}`;
|
||||
|
||||
if (Helper.config.fileUpload.baseUrl) {
|
||||
|
@ -213,31 +246,55 @@ class Uploader {
|
|||
uploadUrl = `uploads/${uploadUrl}`;
|
||||
}
|
||||
|
||||
// Sharps prebuilt libvips does not include gif support, but that is not a problem,
|
||||
// as GIFs don't support EXIF metadata or anything alike
|
||||
const isImage = contentType.startsWith("image/") && !contentType.endsWith("gif");
|
||||
|
||||
// if the busboy data stream errors out or goes over the file size limit
|
||||
// abort the processing with an error
|
||||
fileStream.on("error", abortWithError);
|
||||
fileStream.on("limit", () => {
|
||||
fileStream.unpipe(streamWriter);
|
||||
if (!isImage) {
|
||||
fileStream.unpipe(streamWriter);
|
||||
}
|
||||
|
||||
fileStream.on("readable", fileStream.read.bind(fileStream));
|
||||
|
||||
abortWithError(Error("File size limit reached"));
|
||||
});
|
||||
|
||||
// Attempt to write the stream to file
|
||||
fileStream.pipe(streamWriter);
|
||||
});
|
||||
if (isImage) {
|
||||
let sharpInstance = sharp({
|
||||
animated: true,
|
||||
pages: -1,
|
||||
sequentialRead: true,
|
||||
});
|
||||
|
||||
busboyInstance.on("finish", () => {
|
||||
doneCallback();
|
||||
if (!removeMetadata) {
|
||||
sharpInstance = sharpInstance.withMetadata();
|
||||
}
|
||||
|
||||
if (!uploadUrl) {
|
||||
return res.status(400).json({error: "Missing file"});
|
||||
sharpInstance
|
||||
.rotate() // auto-orient based on the EXIF Orientation tag
|
||||
.toFile(destPath, (err) => {
|
||||
// Removes metadata by default https://sharp.pixelplumbing.com/api-output#tofile if no `withMetadata` is present
|
||||
if (err) {
|
||||
abortWithError(err);
|
||||
} else {
|
||||
successfullCompletion();
|
||||
}
|
||||
});
|
||||
|
||||
fileStream.pipe(sharpInstance);
|
||||
} else {
|
||||
// Open a file stream for writing
|
||||
streamWriter = fs.createWriteStream(destPath);
|
||||
streamWriter.on("error", abortWithError);
|
||||
streamWriter.on("finish", successfullCompletion);
|
||||
|
||||
// Attempt to write the stream to file
|
||||
fileStream.pipe(streamWriter);
|
||||
}
|
||||
|
||||
// upload was done, send the generated file url to the client
|
||||
res.status(200).json({
|
||||
url: uploadUrl,
|
||||
});
|
||||
});
|
||||
|
||||
// pipe request body to busboy for processing
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
@ -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>",
|
||||
|
|
2
test/fixtures/.thelounge/config.js
vendored
2
test/fixtures/.thelounge/config.js
vendored
|
@ -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";
|
||||
|
|
|
@ -91,7 +91,7 @@ describe("Network", function () {
|
|||
rejectUnauthorized: false,
|
||||
});
|
||||
expect(network.validate()).to.be.true;
|
||||
expect(network.host).to.equal("chat.freenode.net");
|
||||
expect(network.host).to.equal("irc.example.com");
|
||||
expect(network.port).to.equal(6697);
|
||||
expect(network.tls).to.be.true;
|
||||
expect(network.rejectUnauthorized).to.be.true;
|
||||
|
@ -103,7 +103,7 @@ describe("Network", function () {
|
|||
host: "some.fake.tld",
|
||||
});
|
||||
expect(network2.validate()).to.be.true;
|
||||
expect(network2.host).to.equal("chat.freenode.net");
|
||||
expect(network2.host).to.equal("irc.example.com");
|
||||
|
||||
Helper.config.lockNetwork = false;
|
||||
});
|
||||
|
@ -273,7 +273,7 @@ describe("Network", function () {
|
|||
// Lobby and initial channel
|
||||
expect(network.channels.length).to.equal(2);
|
||||
|
||||
const newChan = new Chan({name: "#freenode"});
|
||||
const newChan = new Chan({name: "#foo"});
|
||||
network.addChannel(newChan);
|
||||
|
||||
expect(network.channels.length).to.equal(3);
|
||||
|
@ -286,13 +286,13 @@ describe("Network", function () {
|
|||
|
||||
const network = new Network({
|
||||
channels: [chan1, chan2, chan3],
|
||||
name: "freenode",
|
||||
name: "foo",
|
||||
});
|
||||
|
||||
const newChan = new Chan({name: "#freenode"});
|
||||
const newChan = new Chan({name: "#foo"});
|
||||
network.addChannel(newChan);
|
||||
|
||||
expect(network.channels[0].name).to.equal("freenode");
|
||||
expect(network.channels[0].name).to.equal("foo");
|
||||
expect(network.channels[1]).to.equal(chan1);
|
||||
expect(network.channels[2]).to.equal(newChan);
|
||||
expect(network.channels[3]).to.equal(chan2);
|
||||
|
@ -307,7 +307,7 @@ describe("Network", function () {
|
|||
channels: [chan1, chan2],
|
||||
});
|
||||
|
||||
const newChan = new Chan({name: "#freenode"});
|
||||
const newChan = new Chan({name: "#foo"});
|
||||
network.addChannel(newChan);
|
||||
|
||||
expect(network.channels[1]).to.equal(chan1);
|
||||
|
@ -401,7 +401,7 @@ describe("Network", function () {
|
|||
channels: [banlist, chan1, user1],
|
||||
});
|
||||
|
||||
const newChan = new Chan({name: "#freenode"});
|
||||
const newChan = new Chan({name: "#foo"});
|
||||
network.addChannel(newChan);
|
||||
|
||||
expect(network.channels[1]).to.equal(newChan);
|
||||
|
@ -412,7 +412,7 @@ describe("Network", function () {
|
|||
|
||||
it("should never add something in front of the lobby", function () {
|
||||
const network = new Network({
|
||||
name: "freenode",
|
||||
name: "foo",
|
||||
channels: [],
|
||||
});
|
||||
|
||||
|
|
|
@ -85,6 +85,7 @@ const config = {
|
|||
},
|
||||
externals: {
|
||||
json3: "JSON", // socket.io uses json3.js, but we do not target any browsers that need it
|
||||
sharp: "commonjs sharp",
|
||||
},
|
||||
plugins: [
|
||||
new VueLoaderPlugin(),
|
||||
|
|
Loading…
Reference in a new issue