Initial comission of TheLounge base files
3841
CHANGELOG.md
Normal file
22
LICENSE
Normal file
@ -0,0 +1,22 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2016 All contributors to The Lounge
|
||||
Copyright (c) 2014 Mattias Erming and contributors, as part of Shout.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
9
SECURITY.md
Normal file
@ -0,0 +1,9 @@
|
||||
# Responsible Disclosure of Security Vulnerabilities
|
||||
|
||||
- ⚠️ **Do not open public issues on GitHub to report security vulnerabilities.**
|
||||
- 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,
|
||||
`#thelounge`.
|
||||
- By email, send us your report at <security@thelounge.chat>.
|
BIN
client/audio/pop.wav
Normal file
129
client/components/App.vue
Normal file
@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div id="viewport" :class="viewportClasses" role="tablist">
|
||||
<Sidebar v-if="$store.state.appLoaded" :overlay="$refs.overlay" />
|
||||
<div id="sidebar-overlay" ref="overlay" @click="$store.commit('sidebarOpen', false)" />
|
||||
<router-view ref="window"></router-view>
|
||||
<Mentions />
|
||||
<ImageViewer ref="imageViewer" />
|
||||
<ContextMenu ref="contextMenu" />
|
||||
<ConfirmDialog ref="confirmDialog" />
|
||||
<div id="upload-overlay"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const constants = require("../js/constants");
|
||||
import eventbus from "../js/eventbus";
|
||||
import Mousetrap from "mousetrap";
|
||||
import throttle from "lodash/throttle";
|
||||
import storage from "../js/localStorage";
|
||||
import isIgnoredKeybind from "../js/helpers/isIgnoredKeybind";
|
||||
|
||||
import Sidebar from "./Sidebar.vue";
|
||||
import ImageViewer from "./ImageViewer.vue";
|
||||
import ContextMenu from "./ContextMenu.vue";
|
||||
import ConfirmDialog from "./ConfirmDialog.vue";
|
||||
import Mentions from "./Mentions.vue";
|
||||
|
||||
export default {
|
||||
name: "App",
|
||||
components: {
|
||||
Sidebar,
|
||||
ImageViewer,
|
||||
ContextMenu,
|
||||
ConfirmDialog,
|
||||
Mentions,
|
||||
},
|
||||
computed: {
|
||||
viewportClasses() {
|
||||
return {
|
||||
notified: this.$store.getters.highlightCount > 0,
|
||||
"menu-open": this.$store.state.appLoaded && this.$store.state.sidebarOpen,
|
||||
"menu-dragging": this.$store.state.sidebarDragging,
|
||||
"userlist-open": this.$store.state.userlistOpen,
|
||||
};
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.prepareOpenStates();
|
||||
},
|
||||
mounted() {
|
||||
Mousetrap.bind("esc", this.escapeKey);
|
||||
Mousetrap.bind("alt+u", this.toggleUserList);
|
||||
Mousetrap.bind("alt+s", this.toggleSidebar);
|
||||
|
||||
// Make a single throttled resize listener available to all components
|
||||
this.debouncedResize = throttle(() => {
|
||||
eventbus.emit("resize");
|
||||
}, 100);
|
||||
|
||||
window.addEventListener("resize", this.debouncedResize, {passive: true});
|
||||
|
||||
// Emit a daychange event every time the day changes so date markers know when to update themselves
|
||||
const emitDayChange = () => {
|
||||
eventbus.emit("daychange");
|
||||
// This should always be 24h later but re-computing exact value just in case
|
||||
this.dayChangeTimeout = setTimeout(emitDayChange, this.msUntilNextDay());
|
||||
};
|
||||
|
||||
this.dayChangeTimeout = setTimeout(emitDayChange, this.msUntilNextDay());
|
||||
},
|
||||
beforeDestroy() {
|
||||
Mousetrap.unbind("esc", this.escapeKey);
|
||||
Mousetrap.unbind("alt+u", this.toggleUserList);
|
||||
Mousetrap.unbind("alt+s", this.toggleSidebar);
|
||||
|
||||
window.removeEventListener("resize", this.debouncedResize);
|
||||
clearTimeout(this.dayChangeTimeout);
|
||||
},
|
||||
methods: {
|
||||
escapeKey() {
|
||||
eventbus.emit("escapekey");
|
||||
},
|
||||
toggleSidebar(e) {
|
||||
if (isIgnoredKeybind(e)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.$store.commit("toggleSidebar");
|
||||
|
||||
return false;
|
||||
},
|
||||
toggleUserList(e) {
|
||||
if (isIgnoredKeybind(e)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.$store.commit("toggleUserlist");
|
||||
|
||||
return false;
|
||||
},
|
||||
msUntilNextDay() {
|
||||
// Compute how many milliseconds are remaining until the next day starts
|
||||
const today = new Date();
|
||||
const tommorow = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1);
|
||||
|
||||
return tommorow - today;
|
||||
},
|
||||
prepareOpenStates() {
|
||||
const viewportWidth = window.innerWidth;
|
||||
let isUserlistOpen = storage.get("thelounge.state.userlist");
|
||||
|
||||
if (viewportWidth > constants.mobileViewportPixels) {
|
||||
this.$store.commit(
|
||||
"sidebarOpen",
|
||||
storage.get("thelounge.state.sidebar") !== "false"
|
||||
);
|
||||
}
|
||||
|
||||
// If The Lounge is opened on a small screen (less than 1024px), and we don't have stored
|
||||
// user list state, close it by default
|
||||
if (viewportWidth >= 1024 && isUserlistOpen !== "true" && isUserlistOpen !== "false") {
|
||||
isUserlistOpen = "true";
|
||||
}
|
||||
|
||||
this.$store.commit("userlistOpen", isUserlistOpen === "true");
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
53
client/components/Channel.vue
Normal file
@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<ChannelWrapper ref="wrapper" v-bind="$props">
|
||||
<span class="name">{{ channel.name }}</span>
|
||||
<span v-if="channel.unread" :class="{highlight: channel.highlight}" class="badge">{{
|
||||
unreadCount
|
||||
}}</span>
|
||||
<template v-if="channel.type === 'channel'">
|
||||
<span
|
||||
v-if="channel.state === 0"
|
||||
class="parted-channel-tooltip tooltipped tooltipped-w"
|
||||
aria-label="Not currently joined"
|
||||
>
|
||||
<span class="parted-channel-icon" />
|
||||
</span>
|
||||
<span class="close-tooltip tooltipped tooltipped-w" aria-label="Leave">
|
||||
<button class="close" aria-label="Leave" @click.stop="close" />
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="close-tooltip tooltipped tooltipped-w" aria-label="Close">
|
||||
<button class="close" aria-label="Close" @click.stop="close" />
|
||||
</span>
|
||||
</template>
|
||||
</ChannelWrapper>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import roundBadgeNumber from "../js/helpers/roundBadgeNumber";
|
||||
import ChannelWrapper from "./ChannelWrapper.vue";
|
||||
|
||||
export default {
|
||||
name: "Channel",
|
||||
components: {
|
||||
ChannelWrapper,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
channel: Object,
|
||||
active: Boolean,
|
||||
isFiltering: Boolean,
|
||||
},
|
||||
computed: {
|
||||
unreadCount() {
|
||||
return roundBadgeNumber(this.channel.unread);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
close() {
|
||||
this.$root.closeChannel(this.channel);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
86
client/components/ChannelWrapper.vue
Normal file
@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<!-- TODO: move closed style to it's own class -->
|
||||
<div
|
||||
v-if="isChannelVisible"
|
||||
ref="element"
|
||||
:class="[
|
||||
'channel-list-item',
|
||||
{active: active},
|
||||
{'parted-channel': channel.type === 'channel' && channel.state === 0},
|
||||
{'has-draft': channel.pendingMessage},
|
||||
{
|
||||
'not-secure':
|
||||
channel.type === 'lobby' && network.status.connected && !network.status.secure,
|
||||
},
|
||||
{'not-connected': channel.type === 'lobby' && !network.status.connected},
|
||||
]"
|
||||
:aria-label="getAriaLabel()"
|
||||
:title="getAriaLabel()"
|
||||
:data-name="channel.name"
|
||||
:data-type="channel.type"
|
||||
:aria-controls="'#chan-' + channel.id"
|
||||
:aria-selected="active"
|
||||
:style="channel.closed ? {transition: 'none', opacity: 0.4} : null"
|
||||
role="tab"
|
||||
@click="click"
|
||||
@contextmenu.prevent="openContextMenu"
|
||||
>
|
||||
<slot :network="network" :channel="channel" :activeChannel="activeChannel" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import eventbus from "../js/eventbus";
|
||||
import isChannelCollapsed from "../js/helpers/isChannelCollapsed";
|
||||
|
||||
export default {
|
||||
name: "ChannelWrapper",
|
||||
props: {
|
||||
network: Object,
|
||||
channel: Object,
|
||||
active: Boolean,
|
||||
isFiltering: Boolean,
|
||||
},
|
||||
computed: {
|
||||
activeChannel() {
|
||||
return this.$store.state.activeChannel;
|
||||
},
|
||||
isChannelVisible() {
|
||||
return this.isFiltering || !isChannelCollapsed(this.network, this.channel);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getAriaLabel() {
|
||||
const extra = [];
|
||||
|
||||
if (this.channel.unread > 0) {
|
||||
extra.push(`${this.channel.unread} unread`);
|
||||
}
|
||||
|
||||
if (this.channel.highlight > 0) {
|
||||
extra.push(`${this.channel.highlight} mention`);
|
||||
}
|
||||
|
||||
if (extra.length > 0) {
|
||||
return `${this.channel.name} (${extra.join(", ")})`;
|
||||
}
|
||||
|
||||
return this.channel.name;
|
||||
},
|
||||
click() {
|
||||
if (this.isFiltering) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$root.switchToChannel(this.channel);
|
||||
},
|
||||
openContextMenu(event) {
|
||||
eventbus.emit("contextmenu:channel", {
|
||||
event: event,
|
||||
channel: this.channel,
|
||||
network: this.network,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
215
client/components/Chat.vue
Normal file
@ -0,0 +1,215 @@
|
||||
<template>
|
||||
<div id="chat-container" class="window" :data-current-channel="channel.name" lang="">
|
||||
<div
|
||||
id="chat"
|
||||
:class="{
|
||||
'hide-motd': !$store.state.settings.motd,
|
||||
'colored-nicks': $store.state.settings.coloredNicks,
|
||||
'time-seconds': $store.state.settings.showSeconds,
|
||||
'time-12h': $store.state.settings.use12hClock,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
:id="'chan-' + channel.id"
|
||||
class="chat-view"
|
||||
:data-type="channel.type"
|
||||
:aria-label="channel.name"
|
||||
role="tabpanel"
|
||||
>
|
||||
<div class="header">
|
||||
<SidebarToggle />
|
||||
<span class="title">{{ channel.name }}</span>
|
||||
<!--div v-if="channel.editTopic === true" class="topic-container">
|
||||
<input
|
||||
ref="topicInput"
|
||||
:value="channel.topic"
|
||||
class="topic-input"
|
||||
placeholder="Set channel topic"
|
||||
enterkeyhint="done"
|
||||
@keyup.enter="saveTopic"
|
||||
@keyup.esc="channel.editTopic = false"
|
||||
/>
|
||||
<span aria-label="Save topic" class="save-topic" @click="saveTopic">
|
||||
<span type="button" aria-label="Save topic"></span>
|
||||
</span>
|
||||
</div-->
|
||||
<span v-else :title="channel.topic" class="topic""
|
||||
><ParsedMessage
|
||||
v-if="channel.topic"
|
||||
:network="network"
|
||||
:text="channel.topic"
|
||||
/></span>
|
||||
<button
|
||||
class="mentions"
|
||||
aria-label="Open your mentions"
|
||||
@click="openMentions"
|
||||
/>
|
||||
<button
|
||||
class="menu"
|
||||
aria-label="Open the context menu"
|
||||
@click="openContextMenu"
|
||||
/>
|
||||
<span
|
||||
v-if="channel.type === 'channel'"
|
||||
class="rt-tooltip tooltipped tooltipped-w"
|
||||
aria-label="Toggle user list"
|
||||
>
|
||||
<button
|
||||
class="rt"
|
||||
aria-label="Toggle user list"
|
||||
@click="$store.commit('toggleUserlist')"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="channel.type === 'special'" class="chat-content">
|
||||
<div class="chat">
|
||||
<div class="messages">
|
||||
<div class="msg">
|
||||
<component
|
||||
:is="specialComponent"
|
||||
:network="network"
|
||||
:channel="channel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="chat-content">
|
||||
<div
|
||||
:class="[
|
||||
'scroll-down tooltipped tooltipped-w tooltipped-no-touch',
|
||||
{'scroll-down-shown': !channel.scrolledToBottom},
|
||||
]"
|
||||
aria-label="Jump to recent messages"
|
||||
@click="$refs.messageList.jumpToBottom()"
|
||||
>
|
||||
<div class="scroll-down-arrow" />
|
||||
</div>
|
||||
<MessageList ref="messageList" :network="network" :channel="channel" />
|
||||
<ChatUserList v-if="channel.type === 'channel'" :channel="channel" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="this.$store.state.currentUserVisibleError"
|
||||
id="user-visible-error"
|
||||
@click="hideUserVisibleError"
|
||||
>
|
||||
{{ this.$store.state.currentUserVisibleError }}
|
||||
</div>
|
||||
<ChatInput :network="network" :channel="channel" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import socket from "../js/socket";
|
||||
import eventbus from "../js/eventbus";
|
||||
import ParsedMessage from "./ParsedMessage.vue";
|
||||
import MessageList from "./MessageList.vue";
|
||||
import ChatInput from "./ChatInput.vue";
|
||||
import ChatUserList from "./ChatUserList.vue";
|
||||
import SidebarToggle from "./SidebarToggle.vue";
|
||||
import ListBans from "./Special/ListBans.vue";
|
||||
import ListInvites from "./Special/ListInvites.vue";
|
||||
import ListChannels from "./Special/ListChannels.vue";
|
||||
import ListIgnored from "./Special/ListIgnored.vue";
|
||||
|
||||
export default {
|
||||
name: "Chat",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
MessageList,
|
||||
ChatInput,
|
||||
ChatUserList,
|
||||
SidebarToggle,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
channel: Object,
|
||||
},
|
||||
computed: {
|
||||
specialComponent() {
|
||||
switch (this.channel.special) {
|
||||
case "list_bans":
|
||||
return ListBans;
|
||||
case "list_invites":
|
||||
return ListInvites;
|
||||
case "list_channels":
|
||||
return ListChannels;
|
||||
case "list_ignored":
|
||||
return ListIgnored;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
channel() {
|
||||
this.channelChanged();
|
||||
},
|
||||
"channel.editTopic"(newValue) {
|
||||
if (newValue) {
|
||||
this.$nextTick(() => {
|
||||
this.$refs.topicInput.focus();
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.channelChanged();
|
||||
|
||||
if (this.channel.editTopic) {
|
||||
this.$nextTick(() => {
|
||||
this.$refs.topicInput.focus();
|
||||
});
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
channelChanged() {
|
||||
// Triggered when active channel is set or changed
|
||||
this.channel.highlight = 0;
|
||||
this.channel.unread = 0;
|
||||
|
||||
socket.emit("open", this.channel.id);
|
||||
|
||||
if (this.channel.usersOutdated) {
|
||||
this.channel.usersOutdated = false;
|
||||
|
||||
socket.emit("names", {
|
||||
target: this.channel.id,
|
||||
});
|
||||
}
|
||||
},
|
||||
hideUserVisibleError() {
|
||||
this.$store.commit("currentUserVisibleError", null);
|
||||
},
|
||||
editTopic() {
|
||||
if (this.channel.type === "channel") {
|
||||
this.channel.editTopic = true;
|
||||
}
|
||||
},
|
||||
saveTopic() {
|
||||
this.channel.editTopic = false;
|
||||
const newTopic = this.$refs.topicInput.value;
|
||||
|
||||
if (this.channel.topic !== newTopic) {
|
||||
const target = this.channel.id;
|
||||
const text = `/raw TOPIC ${this.channel.name} :${newTopic}`;
|
||||
socket.emit("input", {target, text});
|
||||
}
|
||||
},
|
||||
openContextMenu(event) {
|
||||
eventbus.emit("contextmenu:channel", {
|
||||
event: event,
|
||||
channel: this.channel,
|
||||
network: this.network,
|
||||
});
|
||||
},
|
||||
openMentions() {
|
||||
eventbus.emit("mentions:toggle", {
|
||||
event: event,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
215
client/components/Chat.vue.orig
Normal file
@ -0,0 +1,215 @@
|
||||
<template>
|
||||
<div id="chat-container" class="window" :data-current-channel="channel.name" lang="">
|
||||
<div
|
||||
id="chat"
|
||||
:class="{
|
||||
'hide-motd': !$store.state.settings.motd,
|
||||
'colored-nicks': $store.state.settings.coloredNicks,
|
||||
'time-seconds': $store.state.settings.showSeconds,
|
||||
'time-12h': $store.state.settings.use12hClock,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
:id="'chan-' + channel.id"
|
||||
class="chat-view"
|
||||
:data-type="channel.type"
|
||||
:aria-label="channel.name"
|
||||
role="tabpanel"
|
||||
>
|
||||
<div class="header">
|
||||
<SidebarToggle />
|
||||
<span class="title">{{ channel.name }}</span>
|
||||
<!--div v-if="channel.editTopic === true" class="topic-container">
|
||||
<input
|
||||
ref="topicInput"
|
||||
:value="channel.topic"
|
||||
class="topic-input"
|
||||
placeholder="Set channel topic"
|
||||
enterkeyhint="done"
|
||||
@keyup.enter="saveTopic"
|
||||
@keyup.esc="channel.editTopic = false"
|
||||
/>
|
||||
<span aria-label="Save topic" class="save-topic" @click="saveTopic">
|
||||
<span type="button" aria-label="Save topic"></span>
|
||||
</span>
|
||||
</div-->
|
||||
<span v-else :title="channel.topic" class="topic" @dblclick="editTopic"
|
||||
><ParsedMessage
|
||||
v-if="channel.topic"
|
||||
:network="network"
|
||||
:text="channel.topic"
|
||||
/></span>
|
||||
<button
|
||||
class="mentions"
|
||||
aria-label="Open your mentions"
|
||||
@click="openMentions"
|
||||
/>
|
||||
<button
|
||||
class="menu"
|
||||
aria-label="Open the context menu"
|
||||
@click="openContextMenu"
|
||||
/>
|
||||
<span
|
||||
v-if="channel.type === 'channel'"
|
||||
class="rt-tooltip tooltipped tooltipped-w"
|
||||
aria-label="Toggle user list"
|
||||
>
|
||||
<button
|
||||
class="rt"
|
||||
aria-label="Toggle user list"
|
||||
@click="$store.commit('toggleUserlist')"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="channel.type === 'special'" class="chat-content">
|
||||
<div class="chat">
|
||||
<div class="messages">
|
||||
<div class="msg">
|
||||
<component
|
||||
:is="specialComponent"
|
||||
:network="network"
|
||||
:channel="channel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="chat-content">
|
||||
<div
|
||||
:class="[
|
||||
'scroll-down tooltipped tooltipped-w tooltipped-no-touch',
|
||||
{'scroll-down-shown': !channel.scrolledToBottom},
|
||||
]"
|
||||
aria-label="Jump to recent messages"
|
||||
@click="$refs.messageList.jumpToBottom()"
|
||||
>
|
||||
<div class="scroll-down-arrow" />
|
||||
</div>
|
||||
<MessageList ref="messageList" :network="network" :channel="channel" />
|
||||
<ChatUserList v-if="channel.type === 'channel'" :channel="channel" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="this.$store.state.currentUserVisibleError"
|
||||
id="user-visible-error"
|
||||
@click="hideUserVisibleError"
|
||||
>
|
||||
{{ this.$store.state.currentUserVisibleError }}
|
||||
</div>
|
||||
<ChatInput :network="network" :channel="channel" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import socket from "../js/socket";
|
||||
import eventbus from "../js/eventbus";
|
||||
import ParsedMessage from "./ParsedMessage.vue";
|
||||
import MessageList from "./MessageList.vue";
|
||||
import ChatInput from "./ChatInput.vue";
|
||||
import ChatUserList from "./ChatUserList.vue";
|
||||
import SidebarToggle from "./SidebarToggle.vue";
|
||||
import ListBans from "./Special/ListBans.vue";
|
||||
import ListInvites from "./Special/ListInvites.vue";
|
||||
import ListChannels from "./Special/ListChannels.vue";
|
||||
import ListIgnored from "./Special/ListIgnored.vue";
|
||||
|
||||
export default {
|
||||
name: "Chat",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
MessageList,
|
||||
ChatInput,
|
||||
ChatUserList,
|
||||
SidebarToggle,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
channel: Object,
|
||||
},
|
||||
computed: {
|
||||
specialComponent() {
|
||||
switch (this.channel.special) {
|
||||
case "list_bans":
|
||||
return ListBans;
|
||||
case "list_invites":
|
||||
return ListInvites;
|
||||
case "list_channels":
|
||||
return ListChannels;
|
||||
case "list_ignored":
|
||||
return ListIgnored;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
channel() {
|
||||
this.channelChanged();
|
||||
},
|
||||
"channel.editTopic"(newValue) {
|
||||
if (newValue) {
|
||||
this.$nextTick(() => {
|
||||
this.$refs.topicInput.focus();
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.channelChanged();
|
||||
|
||||
if (this.channel.editTopic) {
|
||||
this.$nextTick(() => {
|
||||
this.$refs.topicInput.focus();
|
||||
});
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
channelChanged() {
|
||||
// Triggered when active channel is set or changed
|
||||
this.channel.highlight = 0;
|
||||
this.channel.unread = 0;
|
||||
|
||||
socket.emit("open", this.channel.id);
|
||||
|
||||
if (this.channel.usersOutdated) {
|
||||
this.channel.usersOutdated = false;
|
||||
|
||||
socket.emit("names", {
|
||||
target: this.channel.id,
|
||||
});
|
||||
}
|
||||
},
|
||||
hideUserVisibleError() {
|
||||
this.$store.commit("currentUserVisibleError", null);
|
||||
},
|
||||
editTopic() {
|
||||
if (this.channel.type === "channel") {
|
||||
this.channel.editTopic = true;
|
||||
}
|
||||
},
|
||||
saveTopic() {
|
||||
this.channel.editTopic = false;
|
||||
const newTopic = this.$refs.topicInput.value;
|
||||
|
||||
if (this.channel.topic !== newTopic) {
|
||||
const target = this.channel.id;
|
||||
const text = `/raw TOPIC ${this.channel.name} :${newTopic}`;
|
||||
socket.emit("input", {target, text});
|
||||
}
|
||||
},
|
||||
openContextMenu(event) {
|
||||
eventbus.emit("contextmenu:channel", {
|
||||
event: event,
|
||||
channel: this.channel,
|
||||
network: this.network,
|
||||
});
|
||||
},
|
||||
openMentions() {
|
||||
eventbus.emit("mentions:toggle", {
|
||||
event: event,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
270
client/components/ChatInput.vue
Normal file
@ -0,0 +1,270 @@
|
||||
<template>
|
||||
<form id="form" method="post" action="" @submit.prevent="onSubmit">
|
||||
<span id="upload-progressbar" />
|
||||
<span id="nick">{{ network.nick }}</span>
|
||||
<textarea
|
||||
id="input"
|
||||
ref="input"
|
||||
dir="auto"
|
||||
class="mousetrap"
|
||||
enterkeyhint="send"
|
||||
:value="channel.pendingMessage"
|
||||
:placeholder="getInputPlaceholder(channel)"
|
||||
:aria-label="getInputPlaceholder(channel)"
|
||||
@input="setPendingMessage"
|
||||
@keypress.enter.exact.prevent="onSubmit"
|
||||
/>
|
||||
<span
|
||||
v-if="$store.state.serverConfiguration.fileUpload"
|
||||
id="upload-tooltip"
|
||||
class="tooltipped tooltipped-w tooltipped-no-touch"
|
||||
aria-label="Upload file"
|
||||
@click="openFileUpload"
|
||||
>
|
||||
<input
|
||||
id="upload-input"
|
||||
ref="uploadInput"
|
||||
type="file"
|
||||
aria-labelledby="upload"
|
||||
multiple
|
||||
@change="onUploadInputChange"
|
||||
/>
|
||||
<button
|
||||
id="upload"
|
||||
type="button"
|
||||
aria-label="Upload file"
|
||||
:disabled="!$store.state.isConnected"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
id="submit-tooltip"
|
||||
class="tooltipped tooltipped-w tooltipped-no-touch"
|
||||
aria-label="Send message"
|
||||
>
|
||||
<button
|
||||
id="submit"
|
||||
type="submit"
|
||||
aria-label="Send message"
|
||||
:disabled="!$store.state.isConnected"
|
||||
/>
|
||||
</span>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Mousetrap from "mousetrap";
|
||||
import {wrapCursor} from "undate";
|
||||
import autocompletion from "../js/autocompletion";
|
||||
import commands from "../js/commands/index";
|
||||
import socket from "../js/socket";
|
||||
import upload from "../js/upload";
|
||||
import eventbus from "../js/eventbus";
|
||||
|
||||
const formattingHotkeys = {
|
||||
"mod+k": "\x03",
|
||||
"mod+b": "\x02",
|
||||
"mod+u": "\x1F",
|
||||
"mod+i": "\x1D",
|
||||
"mod+o": "\x0F",
|
||||
"mod+s": "\x1e",
|
||||
"mod+m": "\x11",
|
||||
};
|
||||
|
||||
// Autocomplete bracket and quote characters like in a modern IDE
|
||||
// For example, select `text`, press `[` key, and it becomes `[text]`
|
||||
const bracketWraps = {
|
||||
'"': '"',
|
||||
"'": "'",
|
||||
"(": ")",
|
||||
"<": ">",
|
||||
"[": "]",
|
||||
"{": "}",
|
||||
"*": "*",
|
||||
"`": "`",
|
||||
"~": "~",
|
||||
_: "_",
|
||||
};
|
||||
|
||||
let autocompletionRef = null;
|
||||
|
||||
export default {
|
||||
name: "ChatInput",
|
||||
props: {
|
||||
network: Object,
|
||||
channel: Object,
|
||||
},
|
||||
watch: {
|
||||
"channel.id"() {
|
||||
if (autocompletionRef) {
|
||||
autocompletionRef.hide();
|
||||
}
|
||||
},
|
||||
"channel.pendingMessage"() {
|
||||
this.setInputSize();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
eventbus.on("escapekey", this.blurInput);
|
||||
|
||||
if (this.$store.state.settings.autocomplete) {
|
||||
autocompletionRef = autocompletion(this.$refs.input);
|
||||
}
|
||||
|
||||
const inputTrap = Mousetrap(this.$refs.input);
|
||||
|
||||
inputTrap.bind(Object.keys(formattingHotkeys), function (e, key) {
|
||||
const modifier = formattingHotkeys[key];
|
||||
|
||||
wrapCursor(
|
||||
e.target,
|
||||
modifier,
|
||||
e.target.selectionStart === e.target.selectionEnd ? "" : modifier
|
||||
);
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
inputTrap.bind(Object.keys(bracketWraps), function (e, key) {
|
||||
if (e.target.selectionStart !== e.target.selectionEnd) {
|
||||
wrapCursor(e.target, key, bracketWraps[key]);
|
||||
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
inputTrap.bind(["up", "down"], (e, key) => {
|
||||
if (
|
||||
this.$store.state.isAutoCompleting ||
|
||||
e.target.selectionStart !== e.target.selectionEnd
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {channel} = this;
|
||||
|
||||
if (channel.inputHistoryPosition === 0) {
|
||||
channel.inputHistory[channel.inputHistoryPosition] = channel.pendingMessage;
|
||||
}
|
||||
|
||||
if (key === "up") {
|
||||
if (channel.inputHistoryPosition < channel.inputHistory.length - 1) {
|
||||
channel.inputHistoryPosition++;
|
||||
}
|
||||
} else if (channel.inputHistoryPosition > 0) {
|
||||
channel.inputHistoryPosition--;
|
||||
}
|
||||
|
||||
channel.pendingMessage = channel.inputHistory[channel.inputHistoryPosition];
|
||||
this.$refs.input.value = channel.pendingMessage;
|
||||
this.setInputSize();
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
if (this.$store.state.serverConfiguration.fileUpload) {
|
||||
upload.mounted();
|
||||
}
|
||||
},
|
||||
destroyed() {
|
||||
eventbus.off("escapekey", this.blurInput);
|
||||
|
||||
if (autocompletionRef) {
|
||||
autocompletionRef.destroy();
|
||||
autocompletionRef = null;
|
||||
}
|
||||
|
||||
upload.abort();
|
||||
},
|
||||
methods: {
|
||||
setPendingMessage(e) {
|
||||
this.channel.pendingMessage = e.target.value;
|
||||
this.channel.inputHistoryPosition = 0;
|
||||
this.setInputSize();
|
||||
},
|
||||
setInputSize() {
|
||||
this.$nextTick(() => {
|
||||
const style = window.getComputedStyle(this.$refs.input);
|
||||
const lineHeight = parseFloat(style.lineHeight, 10) || 1;
|
||||
|
||||
// Start by resetting height before computing as scrollHeight does not
|
||||
// decrease when deleting characters
|
||||
this.$refs.input.style.height = "";
|
||||
|
||||
// Use scrollHeight to calculate how many lines there are in input, and ceil the value
|
||||
// because some browsers tend to incorrently round the values when using high density
|
||||
// displays or using page zoom feature
|
||||
this.$refs.input.style.height =
|
||||
Math.ceil(this.$refs.input.scrollHeight / lineHeight) * lineHeight + "px";
|
||||
});
|
||||
},
|
||||
getInputPlaceholder(channel) {
|
||||
if (channel.type === "channel" || channel.type === "query") {
|
||||
return `Write to ${channel.name}`;
|
||||
}
|
||||
|
||||
return "";
|
||||
},
|
||||
onSubmit() {
|
||||
// Triggering click event opens the virtual keyboard on mobile
|
||||
// This can only be called from another interactive event (e.g. button click)
|
||||
this.$refs.input.click();
|
||||
this.$refs.input.focus();
|
||||
|
||||
if (!this.$store.state.isConnected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const target = this.channel.id;
|
||||
const text = this.channel.pendingMessage;
|
||||
|
||||
if (text.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (autocompletionRef) {
|
||||
autocompletionRef.hide();
|
||||
}
|
||||
|
||||
this.channel.inputHistoryPosition = 0;
|
||||
this.channel.pendingMessage = "";
|
||||
this.$refs.input.value = "";
|
||||
this.setInputSize();
|
||||
|
||||
// Store new message in history if last message isn't already equal
|
||||
if (this.channel.inputHistory[1] !== text) {
|
||||
this.channel.inputHistory.splice(1, 0, text);
|
||||
}
|
||||
|
||||
// Limit input history to a 100 entries
|
||||
if (this.channel.inputHistory.length > 100) {
|
||||
this.channel.inputHistory.pop();
|
||||
}
|
||||
|
||||
if (text[0] === "/") {
|
||||
const args = text.substr(1).split(" ");
|
||||
const cmd = args.shift().toLowerCase();
|
||||
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(commands, cmd) &&
|
||||
commands[cmd].input(args)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
socket.emit("input", {target, text});
|
||||
},
|
||||
onUploadInputChange() {
|
||||
const files = Array.from(this.$refs.uploadInput.files);
|
||||
upload.triggerUpload(files);
|
||||
this.$refs.uploadInput.value = ""; // Reset <input> element so you can upload the same file
|
||||
},
|
||||
openFileUpload() {
|
||||
this.$refs.uploadInput.click();
|
||||
},
|
||||
blurInput() {
|
||||
this.$refs.input.blur();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
210
client/components/ChatUserList.vue
Normal file
@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<aside ref="userlist" class="userlist" @mouseleave="removeHoverUser">
|
||||
<div class="count">
|
||||
<input
|
||||
ref="input"
|
||||
:value="userSearchInput"
|
||||
:placeholder="
|
||||
channel.users.length + ' user' + (channel.users.length === 1 ? '' : 's')
|
||||
"
|
||||
type="search"
|
||||
class="search"
|
||||
aria-label="Search among the user list"
|
||||
tabindex="-1"
|
||||
@input="setUserSearchInput"
|
||||
@keydown.up="navigateUserList($event, -1)"
|
||||
@keydown.down="navigateUserList($event, 1)"
|
||||
@keydown.page-up="navigateUserList($event, -10)"
|
||||
@keydown.page-down="navigateUserList($event, 10)"
|
||||
@keydown.enter="selectUser"
|
||||
/>
|
||||
</div>
|
||||
<div class="names">
|
||||
<div
|
||||
v-for="(users, mode) in groupedUsers"
|
||||
:key="mode"
|
||||
:class="['user-mode', getModeClass(mode)]"
|
||||
>
|
||||
<template v-if="userSearchInput.length > 0">
|
||||
<Username
|
||||
v-for="user in users"
|
||||
:key="user.original.nick"
|
||||
:on-hover="hoverUser"
|
||||
:active="user.original === activeUser"
|
||||
:user="user.original"
|
||||
v-html="user.string"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Username
|
||||
v-for="user in users"
|
||||
:key="user.nick"
|
||||
:on-hover="hoverUser"
|
||||
:active="user === activeUser"
|
||||
:user="user"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {filter as fuzzyFilter} from "fuzzy";
|
||||
import Username from "./Username.vue";
|
||||
|
||||
const modes = {
|
||||
"~": "owner",
|
||||
"&": "admin",
|
||||
"!": "admin",
|
||||
"@": "op",
|
||||
"%": "half-op",
|
||||
"+": "voice",
|
||||
"": "normal",
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "ChatUserList",
|
||||
components: {
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
channel: Object,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
userSearchInput: "",
|
||||
activeUser: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
// filteredUsers is computed, to avoid unnecessary filtering
|
||||
// as it is shared between filtering and keybindings.
|
||||
filteredUsers() {
|
||||
if (!this.userSearchInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
return fuzzyFilter(this.userSearchInput, this.channel.users, {
|
||||
pre: "<b>",
|
||||
post: "</b>",
|
||||
extract: (u) => u.nick,
|
||||
});
|
||||
},
|
||||
groupedUsers() {
|
||||
const groups = {};
|
||||
|
||||
if (this.userSearchInput) {
|
||||
const result = this.filteredUsers;
|
||||
|
||||
for (const user of result) {
|
||||
const mode = user.original.modes[0] || "";
|
||||
|
||||
if (!groups[mode]) {
|
||||
groups[mode] = [];
|
||||
}
|
||||
|
||||
// Prepend user mode to search result
|
||||
user.string = mode + user.string;
|
||||
|
||||
groups[mode].push(user);
|
||||
}
|
||||
} else {
|
||||
for (const user of this.channel.users) {
|
||||
const mode = user.modes[0] || "";
|
||||
|
||||
if (!groups[mode]) {
|
||||
groups[mode] = [user];
|
||||
} else {
|
||||
groups[mode].push(user);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
setUserSearchInput(e) {
|
||||
this.userSearchInput = e.target.value;
|
||||
},
|
||||
getModeClass(mode) {
|
||||
return modes[mode];
|
||||
},
|
||||
selectUser() {
|
||||
// Simulate a click on the active user to open the context menu.
|
||||
// Coordinates are provided to position the menu correctly.
|
||||
if (!this.activeUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
const el = this.$refs.userlist.querySelector(".active");
|
||||
const rect = el.getBoundingClientRect();
|
||||
const ev = new MouseEvent("click", {
|
||||
view: window,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: rect.left,
|
||||
clientY: rect.top + rect.height,
|
||||
});
|
||||
el.dispatchEvent(ev);
|
||||
},
|
||||
hoverUser(user) {
|
||||
this.activeUser = user;
|
||||
},
|
||||
removeHoverUser() {
|
||||
this.activeUser = null;
|
||||
},
|
||||
navigateUserList(event, direction) {
|
||||
// Prevent propagation to stop global keybind handler from capturing pagedown/pageup
|
||||
// and redirecting it to the message list container for scrolling
|
||||
event.stopImmediatePropagation();
|
||||
event.preventDefault();
|
||||
|
||||
let users = this.channel.users;
|
||||
|
||||
// Only using filteredUsers when we have to avoids filtering when it's not needed
|
||||
if (this.userSearchInput) {
|
||||
users = this.filteredUsers.map((result) => result.original);
|
||||
}
|
||||
|
||||
// Bail out if there's no users to select
|
||||
if (!users.length) {
|
||||
this.activeUser = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let currentIndex = users.indexOf(this.activeUser);
|
||||
|
||||
// If there's no active user select the first or last one depending on direction
|
||||
if (!this.activeUser || currentIndex === -1) {
|
||||
this.activeUser = direction ? users[0] : users[users.length - 1];
|
||||
this.scrollToActiveUser();
|
||||
return;
|
||||
}
|
||||
|
||||
currentIndex += direction;
|
||||
|
||||
// Wrap around the list if necessary. Normaly each loop iterates once at most,
|
||||
// but might iterate more often if pgup or pgdown are used in a very short user list
|
||||
while (currentIndex < 0) {
|
||||
currentIndex += users.length;
|
||||
}
|
||||
|
||||
while (currentIndex > users.length - 1) {
|
||||
currentIndex -= users.length;
|
||||
}
|
||||
|
||||
this.activeUser = users[currentIndex];
|
||||
this.scrollToActiveUser();
|
||||
},
|
||||
scrollToActiveUser() {
|
||||
// Scroll the list if needed after the active class is applied
|
||||
this.$nextTick(() => {
|
||||
const el = this.$refs.userlist.querySelector(".active");
|
||||
el.scrollIntoView({block: "nearest", inline: "nearest"});
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
86
client/components/ConfirmDialog.vue
Normal file
@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div id="confirm-dialog-overlay" :class="{opened: data !== null}">
|
||||
<div v-if="data !== null" id="confirm-dialog">
|
||||
<div class="confirm-text">
|
||||
<div class="confirm-text-title">{{ data.title }}</div>
|
||||
<p>{{ data.text }}</p>
|
||||
</div>
|
||||
<div class="confirm-buttons">
|
||||
<button class="btn btn-cancel" @click="close(false)">Cancel</button>
|
||||
<button class="btn btn-danger" @click="close(true)">{{ data.button }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
#confirm-dialog {
|
||||
background: var(--body-bg-color);
|
||||
color: #fff;
|
||||
margin: 10px;
|
||||
border-radius: 5px;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
#confirm-dialog .confirm-text {
|
||||
padding: 15px;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
#confirm-dialog .confirm-text-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
#confirm-dialog .confirm-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 15px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
#confirm-dialog .confirm-buttons .btn {
|
||||
margin-bottom: 0;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
#confirm-dialog .confirm-buttons .btn-cancel {
|
||||
border-color: transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import eventbus from "../js/eventbus";
|
||||
|
||||
export default {
|
||||
name: "ConfirmDialog",
|
||||
data() {
|
||||
return {
|
||||
data: null,
|
||||
callback: null,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
eventbus.on("escapekey", this.close);
|
||||
eventbus.on("confirm-dialog", this.open);
|
||||
},
|
||||
destroyed() {
|
||||
eventbus.off("escapekey", this.close);
|
||||
eventbus.off("confirm-dialog", this.open);
|
||||
},
|
||||
methods: {
|
||||
open(data, callback) {
|
||||
this.data = data;
|
||||
this.callback = callback;
|
||||
},
|
||||
close(result) {
|
||||
this.data = null;
|
||||
|
||||
if (this.callback) {
|
||||
this.callback(!!result);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
191
client/components/ContextMenu.vue
Normal file
@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
id="context-menu-container"
|
||||
@click="containerClick"
|
||||
@contextmenu.prevent="containerClick"
|
||||
@keydown.exact.up.prevent="navigateMenu(-1)"
|
||||
@keydown.exact.down.prevent="navigateMenu(1)"
|
||||
@keydown.exact.tab.prevent="navigateMenu(1)"
|
||||
@keydown.shift.tab.prevent="navigateMenu(-1)"
|
||||
>
|
||||
<ul
|
||||
id="context-menu"
|
||||
ref="contextMenu"
|
||||
role="menu"
|
||||
:style="style"
|
||||
tabindex="-1"
|
||||
@mouseleave="activeItem = -1"
|
||||
@keydown.enter.prevent="clickActiveItem"
|
||||
>
|
||||
<template v-for="(item, id) of items">
|
||||
<li
|
||||
:key="item.name"
|
||||
:class="[
|
||||
'context-menu-' + item.type,
|
||||
item.class ? 'context-menu-' + item.class : null,
|
||||
{active: id === activeItem},
|
||||
]"
|
||||
role="menuitem"
|
||||
@mouseenter="hoverItem(id)"
|
||||
@click="clickItem(item)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {generateUserContextMenu, generateChannelContextMenu} from "../js/helpers/contextMenu.js";
|
||||
import eventbus from "../js/eventbus";
|
||||
|
||||
export default {
|
||||
name: "ContextMenu",
|
||||
props: {
|
||||
message: Object,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isOpen: false,
|
||||
previousActiveElement: null,
|
||||
items: [],
|
||||
activeItem: -1,
|
||||
style: {
|
||||
left: 0,
|
||||
top: 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
eventbus.on("escapekey", this.close);
|
||||
eventbus.on("contextmenu:user", this.openUserContextMenu);
|
||||
eventbus.on("contextmenu:channel", this.openChannelContextMenu);
|
||||
},
|
||||
destroyed() {
|
||||
eventbus.off("escapekey", this.close);
|
||||
eventbus.off("contextmenu:user", this.openUserContextMenu);
|
||||
eventbus.off("contextmenu:channel", this.openChannelContextMenu);
|
||||
|
||||
this.close();
|
||||
},
|
||||
methods: {
|
||||
openChannelContextMenu(data) {
|
||||
const items = generateChannelContextMenu(this.$root, data.channel, data.network);
|
||||
this.open(data.event, items);
|
||||
},
|
||||
openUserContextMenu(data) {
|
||||
const {network, channel} = this.$store.state.activeChannel;
|
||||
|
||||
const items = generateUserContextMenu(
|
||||
this.$root,
|
||||
channel,
|
||||
network,
|
||||
channel.users.find((u) => u.nick === data.user.nick) || {
|
||||
nick: data.user.nick,
|
||||
modes: [],
|
||||
}
|
||||
);
|
||||
this.open(data.event, items);
|
||||
},
|
||||
open(event, items) {
|
||||
event.preventDefault();
|
||||
|
||||
this.previousActiveElement = document.activeElement;
|
||||
this.items = items;
|
||||
this.activeItem = 0;
|
||||
this.isOpen = true;
|
||||
|
||||
// Position the menu and set the focus on the first item after it's size has updated
|
||||
this.$nextTick(() => {
|
||||
const pos = this.positionContextMenu(event);
|
||||
this.style.left = pos.left + "px";
|
||||
this.style.top = pos.top + "px";
|
||||
this.$refs.contextMenu.focus();
|
||||
});
|
||||
},
|
||||
close() {
|
||||
if (!this.isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isOpen = false;
|
||||
this.items = [];
|
||||
|
||||
if (this.previousActiveElement) {
|
||||
this.previousActiveElement.focus();
|
||||
this.previousActiveElement = null;
|
||||
}
|
||||
},
|
||||
hoverItem(id) {
|
||||
this.activeItem = id;
|
||||
},
|
||||
clickItem(item) {
|
||||
this.close();
|
||||
|
||||
if (item.action) {
|
||||
item.action();
|
||||
} else if (item.link) {
|
||||
this.$router.push(item.link);
|
||||
}
|
||||
},
|
||||
clickActiveItem() {
|
||||
if (this.items[this.activeItem]) {
|
||||
this.clickItem(this.items[this.activeItem]);
|
||||
}
|
||||
},
|
||||
navigateMenu(direction) {
|
||||
let currentIndex = this.activeItem;
|
||||
|
||||
currentIndex += direction;
|
||||
|
||||
const nextItem = this.items[currentIndex];
|
||||
|
||||
// If the next item we would select is a divider, skip over it
|
||||
if (nextItem && nextItem.type === "divider") {
|
||||
currentIndex += direction;
|
||||
}
|
||||
|
||||
if (currentIndex < 0) {
|
||||
currentIndex += this.items.length;
|
||||
}
|
||||
|
||||
if (currentIndex > this.items.length - 1) {
|
||||
currentIndex -= this.items.length;
|
||||
}
|
||||
|
||||
this.activeItem = currentIndex;
|
||||
},
|
||||
containerClick(event) {
|
||||
if (event.currentTarget === event.target) {
|
||||
this.close();
|
||||
}
|
||||
},
|
||||
positionContextMenu(event) {
|
||||
const element = event.target;
|
||||
const menuWidth = this.$refs.contextMenu.offsetWidth;
|
||||
const menuHeight = this.$refs.contextMenu.offsetHeight;
|
||||
|
||||
if (element && element.classList.contains("menu")) {
|
||||
return {
|
||||
left: element.getBoundingClientRect().left - (menuWidth - element.offsetWidth),
|
||||
top: element.getBoundingClientRect().top + element.offsetHeight,
|
||||
};
|
||||
}
|
||||
|
||||
const offset = {left: event.pageX, top: event.pageY};
|
||||
|
||||
if (window.innerWidth - offset.left < menuWidth) {
|
||||
offset.left = window.innerWidth - menuWidth;
|
||||
}
|
||||
|
||||
if (window.innerHeight - offset.top < menuHeight) {
|
||||
offset.top = window.innerHeight - menuHeight;
|
||||
}
|
||||
|
||||
return offset;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
56
client/components/DateMarker.vue
Normal file
@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div :aria-label="localeDate" class="date-marker-container tooltipped tooltipped-s">
|
||||
<div class="date-marker">
|
||||
<span :aria-label="friendlyDate()" class="date-marker-text" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import dayjs from "dayjs";
|
||||
import calendar from "dayjs/plugin/calendar";
|
||||
import eventbus from "../js/eventbus";
|
||||
|
||||
dayjs.extend(calendar);
|
||||
|
||||
export default {
|
||||
name: "DateMarker",
|
||||
props: {
|
||||
message: Object,
|
||||
},
|
||||
computed: {
|
||||
localeDate() {
|
||||
return dayjs(this.message.time).format("D MMMM YYYY");
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (this.hoursPassed() < 48) {
|
||||
eventbus.on("daychange", this.dayChange);
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
eventbus.off("daychange", this.dayChange);
|
||||
},
|
||||
methods: {
|
||||
hoursPassed() {
|
||||
return (Date.now() - Date.parse(this.message.time)) / 3600000;
|
||||
},
|
||||
dayChange() {
|
||||
this.$forceUpdate();
|
||||
|
||||
if (this.hoursPassed() >= 48) {
|
||||
eventbus.off("daychange", this.dayChange);
|
||||
}
|
||||
},
|
||||
friendlyDate() {
|
||||
// See http://momentjs.com/docs/#/displaying/calendar-time/
|
||||
return dayjs(this.message.time).calendar(null, {
|
||||
sameDay: "[Today]",
|
||||
lastDay: "[Yesterday]",
|
||||
lastWeek: "D MMMM YYYY",
|
||||
sameElse: "D MMMM YYYY",
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
405
client/components/ImageViewer.vue
Normal file
@ -0,0 +1,405 @@
|
||||
<template>
|
||||
<div
|
||||
id="image-viewer"
|
||||
ref="viewer"
|
||||
:class="{opened: link !== null}"
|
||||
@wheel="onMouseWheel"
|
||||
@touchstart.passive="onTouchStart"
|
||||
@click="onClick"
|
||||
>
|
||||
<template v-if="link !== null">
|
||||
<button class="close-btn" aria-label="Close"></button>
|
||||
|
||||
<button
|
||||
v-if="previousImage"
|
||||
class="previous-image-btn"
|
||||
aria-label="Previous image"
|
||||
@click.stop="previous"
|
||||
></button>
|
||||
<button
|
||||
v-if="nextImage"
|
||||
class="next-image-btn"
|
||||
aria-label="Next image"
|
||||
@click.stop="next"
|
||||
></button>
|
||||
|
||||
<a class="open-btn" :href="link.link" target="_blank" rel="noopener"></a>
|
||||
|
||||
<img
|
||||
ref="image"
|
||||
:src="link.thumb"
|
||||
alt=""
|
||||
:style="computeImageStyles"
|
||||
@load="onImageLoad"
|
||||
@mousedown="onImageMouseDown"
|
||||
@touchstart.passive="onImageTouchStart"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Mousetrap from "mousetrap";
|
||||
import eventbus from "../js/eventbus";
|
||||
|
||||
export default {
|
||||
name: "ImageViewer",
|
||||
data() {
|
||||
return {
|
||||
link: null,
|
||||
previousImage: null,
|
||||
nextImage: null,
|
||||
channel: null,
|
||||
|
||||
position: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
transform: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
computeImageStyles() {
|
||||
// Sub pixels may cause the image to blur in certain browsers
|
||||
// round it down to prevent that
|
||||
const transformX = Math.floor(this.transform.x);
|
||||
const transformY = Math.floor(this.transform.y);
|
||||
|
||||
return {
|
||||
left: `${this.position.x}px`,
|
||||
top: `${this.position.y}px`,
|
||||
transform: `translate3d(${transformX}px, ${transformY}px, 0) scale3d(${this.transform.scale}, ${this.transform.scale}, 1)`,
|
||||
};
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
link(newLink, oldLink) {
|
||||
// TODO: history.pushState
|
||||
if (newLink === null) {
|
||||
eventbus.off("escapekey", this.closeViewer);
|
||||
eventbus.off("resize", this.correctPosition);
|
||||
Mousetrap.unbind("left", this.previous);
|
||||
Mousetrap.unbind("right", this.next);
|
||||
return;
|
||||
}
|
||||
|
||||
this.setPrevNextImages();
|
||||
|
||||
if (!oldLink) {
|
||||
eventbus.on("escapekey", this.closeViewer);
|
||||
eventbus.on("resize", this.correctPosition);
|
||||
Mousetrap.bind("left", this.previous);
|
||||
Mousetrap.bind("right", this.next);
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
closeViewer() {
|
||||
if (this.link === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.channel = null;
|
||||
this.previousImage = null;
|
||||
this.nextImage = null;
|
||||
this.link = null;
|
||||
},
|
||||
setPrevNextImages() {
|
||||
if (!this.channel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const links = this.channel.messages
|
||||
.map((msg) => msg.previews)
|
||||
.flat()
|
||||
.filter((preview) => preview.thumb);
|
||||
|
||||
const currentIndex = links.indexOf(this.link);
|
||||
|
||||
this.previousImage = links[currentIndex - 1] || null;
|
||||
this.nextImage = links[currentIndex + 1] || null;
|
||||
},
|
||||
previous() {
|
||||
if (this.previousImage) {
|
||||
this.link = this.previousImage;
|
||||
}
|
||||
},
|
||||
next() {
|
||||
if (this.nextImage) {
|
||||
this.link = this.nextImage;
|
||||
}
|
||||
},
|
||||
onImageLoad() {
|
||||
this.prepareImage();
|
||||
},
|
||||
prepareImage() {
|
||||
const viewer = this.$refs.viewer;
|
||||
const image = this.$refs.image;
|
||||
const width = viewer.offsetWidth;
|
||||
const height = viewer.offsetHeight;
|
||||
const scale = Math.min(1, width / image.width, height / image.height);
|
||||
|
||||
this.position.x = Math.floor(-image.naturalWidth / 2);
|
||||
this.position.y = Math.floor(-image.naturalHeight / 2);
|
||||
this.transform.scale = Math.max(scale, 0.1);
|
||||
this.transform.x = width / 2;
|
||||
this.transform.y = height / 2;
|
||||
},
|
||||
calculateZoomShift(newScale, x, y, oldScale) {
|
||||
const imageWidth = this.$refs.image.width;
|
||||
const centerX = this.$refs.viewer.offsetWidth / 2;
|
||||
const centerY = this.$refs.viewer.offsetHeight / 2;
|
||||
|
||||
return {
|
||||
x:
|
||||
centerX -
|
||||
((centerX - (y - (imageWidth * x) / 2)) / x) * newScale +
|
||||
(imageWidth * newScale) / 2,
|
||||
y:
|
||||
centerY -
|
||||
((centerY - (oldScale - (imageWidth * x) / 2)) / x) * newScale +
|
||||
(imageWidth * newScale) / 2,
|
||||
};
|
||||
},
|
||||
correctPosition() {
|
||||
const image = this.$refs.image;
|
||||
const widthScaled = image.width * this.transform.scale;
|
||||
const heightScaled = image.height * this.transform.scale;
|
||||
const containerWidth = this.$refs.viewer.offsetWidth;
|
||||
const containerHeight = this.$refs.viewer.offsetHeight;
|
||||
|
||||
if (widthScaled < containerWidth) {
|
||||
this.transform.x = containerWidth / 2;
|
||||
} else if (this.transform.x - widthScaled / 2 > 0) {
|
||||
this.transform.x = widthScaled / 2;
|
||||
} else if (this.transform.x + widthScaled / 2 < containerWidth) {
|
||||
this.transform.x = containerWidth - widthScaled / 2;
|
||||
}
|
||||
|
||||
if (heightScaled < containerHeight) {
|
||||
this.transform.y = containerHeight / 2;
|
||||
} else if (this.transform.y - heightScaled / 2 > 0) {
|
||||
this.transform.y = heightScaled / 2;
|
||||
} else if (this.transform.y + heightScaled / 2 < containerHeight) {
|
||||
this.transform.y = containerHeight - heightScaled / 2;
|
||||
}
|
||||
},
|
||||
// Reduce multiple touch points into a single x/y/scale
|
||||
reduceTouches(touches) {
|
||||
let totalX = 0;
|
||||
let totalY = 0;
|
||||
let totalScale = 0;
|
||||
|
||||
for (let i = 0; i < touches.length; i++) {
|
||||
const x = touches[i].clientX;
|
||||
const y = touches[i].clientY;
|
||||
|
||||
totalX += x;
|
||||
totalY += y;
|
||||
|
||||
for (let i2 = 0; i2 < touches.length; i2++) {
|
||||
if (i !== i2) {
|
||||
const x2 = touches[i2].clientX;
|
||||
const y2 = touches[i2].clientY;
|
||||
totalScale += Math.sqrt((x - x2) * (x - x2) + (y - y2) * (y - y2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (totalScale === 0) {
|
||||
totalScale = 1;
|
||||
}
|
||||
|
||||
return {
|
||||
x: totalX / touches.length,
|
||||
y: totalY / touches.length,
|
||||
scale: totalScale / touches.length,
|
||||
};
|
||||
},
|
||||
onTouchStart(e) {
|
||||
// prevent sidebar touchstart event, we don't want to interact with sidebar while in image viewer
|
||||
e.stopImmediatePropagation();
|
||||
},
|
||||
// Touch image manipulation:
|
||||
// 1. Move around by dragging it with one finger
|
||||
// 2. Change image scale by using two fingers
|
||||
onImageTouchStart(e) {
|
||||
const image = this.$refs.image;
|
||||
let touch = this.reduceTouches(e.touches);
|
||||
let currentTouches = e.touches;
|
||||
let touchEndFingers = 0;
|
||||
|
||||
const currentTransform = {
|
||||
x: touch.x,
|
||||
y: touch.y,
|
||||
scale: touch.scale,
|
||||
};
|
||||
|
||||
const startTransform = {
|
||||
x: this.transform.x,
|
||||
y: this.transform.y,
|
||||
scale: this.transform.scale,
|
||||
};
|
||||
|
||||
const touchMove = (moveEvent) => {
|
||||
touch = this.reduceTouches(moveEvent.touches);
|
||||
|
||||
if (currentTouches.length !== moveEvent.touches.length) {
|
||||
currentTransform.x = touch.x;
|
||||
currentTransform.y = touch.y;
|
||||
currentTransform.scale = touch.scale;
|
||||
startTransform.x = this.transform.x;
|
||||
startTransform.y = this.transform.y;
|
||||
startTransform.scale = this.transform.scale;
|
||||
}
|
||||
|
||||
const deltaX = touch.x - currentTransform.x;
|
||||
const deltaY = touch.y - currentTransform.y;
|
||||
const deltaScale = touch.scale / currentTransform.scale;
|
||||
currentTouches = moveEvent.touches;
|
||||
touchEndFingers = 0;
|
||||
|
||||
const newScale = Math.min(3, Math.max(0.1, startTransform.scale * deltaScale));
|
||||
const fixedPosition = this.calculateZoomShift(
|
||||
newScale,
|
||||
startTransform.scale,
|
||||
startTransform.x,
|
||||
startTransform.y
|
||||
);
|
||||
|
||||
this.transform.x = fixedPosition.x + deltaX;
|
||||
this.transform.y = fixedPosition.y + deltaY;
|
||||
this.transform.scale = newScale;
|
||||
this.correctPosition();
|
||||
};
|
||||
|
||||
const touchEnd = (endEvent) => {
|
||||
const changedTouches = endEvent.changedTouches.length;
|
||||
|
||||
if (currentTouches.length > changedTouches + touchEndFingers) {
|
||||
touchEndFingers += changedTouches;
|
||||
return;
|
||||
}
|
||||
|
||||
// todo: this is swipe to close, but it's not working very well due to unfinished delta calculation
|
||||
/* if (
|
||||
this.transform.scale <= 1 &&
|
||||
endEvent.changedTouches[0].clientY - startTransform.y <= -70
|
||||
) {
|
||||
return this.closeViewer();
|
||||
}*/
|
||||
|
||||
this.correctPosition();
|
||||
|
||||
image.removeEventListener("touchmove", touchMove, {passive: true});
|
||||
image.removeEventListener("touchend", touchEnd, {passive: true});
|
||||
};
|
||||
|
||||
image.addEventListener("touchmove", touchMove, {passive: true});
|
||||
image.addEventListener("touchend", touchEnd, {passive: true});
|
||||
},
|
||||
// Image mouse manipulation:
|
||||
// 1. Mouse wheel scrolling will zoom in and out
|
||||
// 2. If image is zoomed in, simply dragging it will move it around
|
||||
onImageMouseDown(e) {
|
||||
// todo: ignore if in touch event currently?
|
||||
// only left mouse
|
||||
if (e.which !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
const viewer = this.$refs.viewer;
|
||||
const image = this.$refs.image;
|
||||
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const startTransformX = this.transform.x;
|
||||
const startTransformY = this.transform.y;
|
||||
const widthScaled = image.width * this.transform.scale;
|
||||
const heightScaled = image.height * this.transform.scale;
|
||||
const containerWidth = viewer.offsetWidth;
|
||||
const containerHeight = viewer.offsetHeight;
|
||||
const centerX = this.transform.x - widthScaled / 2;
|
||||
const centerY = this.transform.y - heightScaled / 2;
|
||||
let movedDistance = 0;
|
||||
|
||||
const mouseMove = (moveEvent) => {
|
||||
moveEvent.stopPropagation();
|
||||
moveEvent.preventDefault();
|
||||
|
||||
const newX = moveEvent.clientX - startX;
|
||||
const newY = moveEvent.clientY - startY;
|
||||
|
||||
movedDistance = Math.max(movedDistance, Math.abs(newX), Math.abs(newY));
|
||||
|
||||
if (centerX < 0 || widthScaled + centerX > containerWidth) {
|
||||
this.transform.x = startTransformX + newX;
|
||||
}
|
||||
|
||||
if (centerY < 0 || heightScaled + centerY > containerHeight) {
|
||||
this.transform.y = startTransformY + newY;
|
||||
}
|
||||
|
||||
this.correctPosition();
|
||||
};
|
||||
|
||||
const mouseUp = (upEvent) => {
|
||||
this.correctPosition();
|
||||
|
||||
if (movedDistance < 2 && upEvent.button === 0) {
|
||||
this.closeViewer();
|
||||
}
|
||||
|
||||
image.removeEventListener("mousemove", mouseMove);
|
||||
image.removeEventListener("mouseup", mouseUp);
|
||||
};
|
||||
|
||||
image.addEventListener("mousemove", mouseMove);
|
||||
image.addEventListener("mouseup", mouseUp);
|
||||
},
|
||||
// If image is zoomed in, holding ctrl while scrolling will move the image up and down
|
||||
onMouseWheel(e) {
|
||||
// if image viewer is closing (css animation), you can still trigger mousewheel
|
||||
// TODO: Figure out a better fix for this
|
||||
if (this.link === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault(); // TODO: Can this be passive?
|
||||
|
||||
if (e.ctrlKey) {
|
||||
this.transform.y += e.deltaY;
|
||||
} else {
|
||||
const delta = e.deltaY > 0 ? 0.1 : -0.1;
|
||||
const newScale = Math.min(3, Math.max(0.1, this.transform.scale + delta));
|
||||
const fixedPosition = this.calculateZoomShift(
|
||||
newScale,
|
||||
this.transform.scale,
|
||||
this.transform.x,
|
||||
this.transform.y
|
||||
);
|
||||
this.transform.scale = newScale;
|
||||
this.transform.x = fixedPosition.x;
|
||||
this.transform.y = fixedPosition.y;
|
||||
}
|
||||
|
||||
this.correctPosition();
|
||||
},
|
||||
onClick(e) {
|
||||
// If click triggers on the image, ignore it
|
||||
if (e.target === this.$refs.image) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.closeViewer();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
30
client/components/InlineChannel.vue
Normal file
@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<span class="inline-channel" dir="auto" role="button" tabindex="0" @click="onClick"
|
||||
><slot></slot
|
||||
></span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import socket from "../js/socket";
|
||||
|
||||
export default {
|
||||
name: "InlineChannel",
|
||||
props: {
|
||||
channel: String,
|
||||
},
|
||||
methods: {
|
||||
onClick() {
|
||||
const existingChannel = this.$store.getters.findChannelOnCurrentNetwork(this.channel);
|
||||
|
||||
if (existingChannel) {
|
||||
this.$root.switchToChannel(existingChannel);
|
||||
}
|
||||
|
||||
socket.emit("input", {
|
||||
target: this.$store.state.activeChannel.channel.id,
|
||||
text: "/join " + this.channel,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
88
client/components/JoinChannel.vue
Normal file
@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<form
|
||||
:id="'join-channel-' + channel.id"
|
||||
class="join-form"
|
||||
method="post"
|
||||
action=""
|
||||
autocomplete="off"
|
||||
@keydown.esc.prevent="$emit('toggle-join-channel')"
|
||||
@submit.prevent="onSubmit"
|
||||
>
|
||||
<input
|
||||
v-model="inputChannel"
|
||||
v-focus
|
||||
type="text"
|
||||
class="input"
|
||||
name="channel"
|
||||
placeholder="Channel"
|
||||
pattern="[^\s]+"
|
||||
maxlength="200"
|
||||
title="The channel name may not contain spaces"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
v-model="inputPassword"
|
||||
type="password"
|
||||
class="input"
|
||||
name="key"
|
||||
placeholder="Password (optional)"
|
||||
pattern="[^\s]+"
|
||||
maxlength="200"
|
||||
title="The channel password may not contain spaces"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<button type="submit" class="btn btn-small">Join</button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import socket from "../js/socket";
|
||||
|
||||
export default {
|
||||
name: "JoinChannel",
|
||||
directives: {
|
||||
focus: {
|
||||
inserted(el) {
|
||||
el.focus();
|
||||
},
|
||||
},
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
channel: Object,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
inputChannel: "",
|
||||
inputPassword: "",
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
onSubmit() {
|
||||
const existingChannel = this.$store.getters.findChannelOnCurrentNetwork(
|
||||
this.inputChannel
|
||||
);
|
||||
|
||||
if (existingChannel) {
|
||||
this.$root.switchToChannel(existingChannel);
|
||||
} else {
|
||||
const chanTypes = this.network.serverOptions.CHANTYPES;
|
||||
let channel = this.inputChannel;
|
||||
|
||||
if (chanTypes && chanTypes.length > 0 && !chanTypes.includes(channel[0])) {
|
||||
channel = chanTypes[0] + channel;
|
||||
}
|
||||
|
||||
socket.emit("input", {
|
||||
text: `/join ${channel} ${this.inputPassword}`,
|
||||
target: this.channel.id,
|
||||
});
|
||||
}
|
||||
|
||||
this.inputChannel = "";
|
||||
this.inputPassword = "";
|
||||
this.$emit("toggle-join-channel");
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
265
client/components/LinkPreview.vue
Normal file
@ -0,0 +1,265 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="link.shown"
|
||||
v-show="link.sourceLoaded || link.type === 'link'"
|
||||
ref="container"
|
||||
class="preview"
|
||||
dir="ltr"
|
||||
>
|
||||
<div
|
||||
ref="content"
|
||||
:class="['toggle-content', 'toggle-type-' + link.type, {opened: isContentShown}]"
|
||||
>
|
||||
<template v-if="link.type === 'link'">
|
||||
<a
|
||||
v-if="link.thumb"
|
||||
v-show="link.sourceLoaded"
|
||||
:href="link.link"
|
||||
class="toggle-thumbnail"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
@click="onThumbnailClick"
|
||||
>
|
||||
<img
|
||||
:src="link.thumb"
|
||||
decoding="async"
|
||||
alt=""
|
||||
class="thumb"
|
||||
@error="onThumbnailError"
|
||||
@abort="onThumbnailError"
|
||||
@load="onPreviewReady"
|
||||
/>
|
||||
</a>
|
||||
<div class="toggle-text" dir="auto">
|
||||
<div class="head">
|
||||
<div class="overflowable">
|
||||
<a
|
||||
:href="link.link"
|
||||
:title="link.head"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>{{ link.head }}</a
|
||||
>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="showMoreButton"
|
||||
:aria-expanded="isContentShown"
|
||||
:aria-label="moreButtonLabel"
|
||||
dir="auto"
|
||||
class="more"
|
||||
@click="onMoreClick"
|
||||
>
|
||||
<span class="more-caret" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="body overflowable">
|
||||
<a :href="link.link" :title="link.body" target="_blank" rel="noopener">{{
|
||||
link.body
|
||||
}}</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="link.type === 'image'">
|
||||
<a
|
||||
:href="link.link"
|
||||
class="toggle-thumbnail"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
@click="onThumbnailClick"
|
||||
>
|
||||
<img
|
||||
v-show="link.sourceLoaded"
|
||||
:src="link.thumb"
|
||||
decoding="async"
|
||||
alt=""
|
||||
@load="onPreviewReady"
|
||||
/>
|
||||
</a>
|
||||
</template>
|
||||
<template v-else-if="link.type === 'video'">
|
||||
<video
|
||||
v-show="link.sourceLoaded"
|
||||
preload="metadata"
|
||||
controls
|
||||
@canplay="onPreviewReady"
|
||||
>
|
||||
<source :src="link.media" :type="link.mediaType" />
|
||||
</video>
|
||||
</template>
|
||||
<template v-else-if="link.type === 'audio'">
|
||||
<audio
|
||||
v-show="link.sourceLoaded"
|
||||
controls
|
||||
preload="metadata"
|
||||
@canplay="onPreviewReady"
|
||||
>
|
||||
<source :src="link.media" :type="link.mediaType" />
|
||||
</audio>
|
||||
</template>
|
||||
<template v-else-if="link.type === 'error'">
|
||||
<em v-if="link.error === 'image-too-big'">
|
||||
This image is larger than {{ imageMaxSize }} and cannot be previewed.
|
||||
<a :href="link.link" target="_blank" rel="noopener">Click here</a>
|
||||
to open it in a new window.
|
||||
</em>
|
||||
<template v-else-if="link.error === 'message'">
|
||||
<div>
|
||||
<em>
|
||||
A preview could not be loaded.
|
||||
<a :href="link.link" target="_blank" rel="noopener">Click here</a>
|
||||
to open it in a new window.
|
||||
</em>
|
||||
<br />
|
||||
<pre class="prefetch-error">{{ link.message }}</pre>
|
||||
</div>
|
||||
|
||||
<button
|
||||
:aria-expanded="isContentShown"
|
||||
:aria-label="moreButtonLabel"
|
||||
class="more"
|
||||
@click="onMoreClick"
|
||||
>
|
||||
<span class="more-caret" />
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import eventbus from "../js/eventbus";
|
||||
import friendlysize from "../js/helpers/friendlysize";
|
||||
|
||||
export default {
|
||||
name: "LinkPreview",
|
||||
props: {
|
||||
link: Object,
|
||||
keepScrollPosition: Function,
|
||||
channel: Object,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showMoreButton: false,
|
||||
isContentShown: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
moreButtonLabel() {
|
||||
return this.isContentShown ? "Less" : "More";
|
||||
},
|
||||
imageMaxSize() {
|
||||
if (!this.link.maxSize) {
|
||||
return;
|
||||
}
|
||||
|
||||
return friendlysize(this.link.maxSize);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
"link.type"() {
|
||||
this.updateShownState();
|
||||
this.onPreviewUpdate();
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.updateShownState();
|
||||
},
|
||||
mounted() {
|
||||
eventbus.on("resize", this.handleResize);
|
||||
|
||||
this.onPreviewUpdate();
|
||||
},
|
||||
beforeDestroy() {
|
||||
eventbus.off("resize", this.handleResize);
|
||||
},
|
||||
destroyed() {
|
||||
// Let this preview go through load/canplay events again,
|
||||
// Otherwise the browser can cause a resize on video elements
|
||||
this.link.sourceLoaded = false;
|
||||
},
|
||||
methods: {
|
||||
onPreviewUpdate() {
|
||||
// Don't display previews while they are loading on the server
|
||||
if (this.link.type === "loading") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Error does not have any media to render
|
||||
if (this.link.type === "error") {
|
||||
this.onPreviewReady();
|
||||
}
|
||||
|
||||
// If link doesn't have a thumbnail, render it
|
||||
if (this.link.type === "link") {
|
||||
this.handleResize();
|
||||
this.keepScrollPosition();
|
||||
}
|
||||
},
|
||||
onPreviewReady() {
|
||||
this.$set(this.link, "sourceLoaded", true);
|
||||
|
||||
this.keepScrollPosition();
|
||||
|
||||
if (this.link.type === "link") {
|
||||
this.handleResize();
|
||||
}
|
||||
},
|
||||
onThumbnailError() {
|
||||
// If thumbnail fails to load, hide it and show the preview without it
|
||||
this.link.thumb = "";
|
||||
this.onPreviewReady();
|
||||
},
|
||||
onThumbnailClick(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const imageViewer = this.$root.$refs.app.$refs.imageViewer;
|
||||
imageViewer.channel = this.channel;
|
||||
imageViewer.link = this.link;
|
||||
},
|
||||
onMoreClick() {
|
||||
this.isContentShown = !this.isContentShown;
|
||||
this.keepScrollPosition();
|
||||
},
|
||||
handleResize() {
|
||||
this.$nextTick(() => {
|
||||
if (!this.$refs.content) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.showMoreButton =
|
||||
this.$refs.content.offsetWidth >= this.$refs.container.offsetWidth;
|
||||
});
|
||||
},
|
||||
updateShownState() {
|
||||
// User has manually toggled the preview, do not apply default
|
||||
if (this.link.shown !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let defaultState = false;
|
||||
|
||||
switch (this.link.type) {
|
||||
case "error":
|
||||
// Collapse all errors by default unless its a message about image being too big
|
||||
if (this.link.error === "image-too-big") {
|
||||
defaultState = this.$store.state.settings.media;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case "link":
|
||||
defaultState = this.$store.state.settings.links;
|
||||
break;
|
||||
|
||||
default:
|
||||
defaultState = this.$store.state.settings.media;
|
||||
}
|
||||
|
||||
this.link.shown = defaultState;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
19
client/components/LinkPreviewFileSize.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<span class="preview-size">({{ previewSize }})</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import friendlysize from "../js/helpers/friendlysize";
|
||||
|
||||
export default {
|
||||
name: "LinkPreviewFileSize",
|
||||
props: {
|
||||
size: Number,
|
||||
},
|
||||
computed: {
|
||||
previewSize() {
|
||||
return friendlysize(this.size);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
29
client/components/LinkPreviewToggle.vue
Normal file
@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<button
|
||||
v-if="link.type !== 'loading'"
|
||||
:class="['toggle-button', 'toggle-preview', {opened: link.shown}]"
|
||||
:aria-label="ariaLabel"
|
||||
@click="onClick"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "LinkPreviewToggle",
|
||||
props: {
|
||||
link: Object,
|
||||
},
|
||||
computed: {
|
||||
ariaLabel() {
|
||||
return this.link.shown ? "Collapse preview" : "Expand preview";
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onClick() {
|
||||
this.link.shown = !this.link.shown;
|
||||
|
||||
this.$parent.$emit("toggle-link-preview", this.link, this.$parent.message);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
221
client/components/Mentions.vue
Normal file
@ -0,0 +1,221 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
id="mentions-popup-container"
|
||||
@click="containerClick"
|
||||
@contextmenu="containerClick"
|
||||
>
|
||||
<div class="mentions-popup">
|
||||
<div class="mentions-popup-title">
|
||||
Recent mentions
|
||||
<button
|
||||
v-if="resolvedMessages.length"
|
||||
class="btn hide-all-mentions"
|
||||
@click="hideAllMentions()"
|
||||
>
|
||||
Hide all
|
||||
</button>
|
||||
</div>
|
||||
<template v-if="resolvedMessages.length === 0">
|
||||
<p v-if="isLoading">Loading…</p>
|
||||
<p v-else>You have no recent mentions.</p>
|
||||
</template>
|
||||
<template v-for="message in resolvedMessages" v-else>
|
||||
<div :key="message.msgId" :class="['msg', message.type]">
|
||||
<div class="mentions-info">
|
||||
<div>
|
||||
<span class="from">
|
||||
<Username :user="message.from" />
|
||||
<template v-if="message.channel">
|
||||
in {{ message.channel.channel.name }} on
|
||||
{{ message.channel.network.name }}
|
||||
</template>
|
||||
<template v-else> in unknown channel </template>
|
||||
</span>
|
||||
<span :title="message.localetime" class="time">
|
||||
{{ messageTime(message.time) }}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="close-tooltip tooltipped tooltipped-w" aria-label="Close">
|
||||
<button
|
||||
class="msg-hide"
|
||||
aria-label="Hide this mention"
|
||||
@click="hideMention(message)"
|
||||
></button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content" dir="auto">
|
||||
<ParsedMessage :network="null" :message="message" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
#mentions-popup-container {
|
||||
z-index: 8;
|
||||
}
|
||||
|
||||
.mentions-popup {
|
||||
background-color: var(--window-bg-color);
|
||||
position: absolute;
|
||||
width: 400px;
|
||||
right: 80px;
|
||||
top: 55px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
z-index: 2;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.mentions-popup > .mentions-popup-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.mentions-popup .mentions-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.mentions-popup .msg {
|
||||
margin-bottom: 15px;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.mentions-popup .msg:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.mentions-popup .msg .content {
|
||||
background-color: var(--highlight-bg-color);
|
||||
border-radius: 5px;
|
||||
padding: 6px;
|
||||
margin-top: 2px;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word; /* Webkit-specific */
|
||||
}
|
||||
|
||||
.mentions-popup .msg-hide::before {
|
||||
font-size: 20px;
|
||||
font-weight: normal;
|
||||
display: inline-block;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
content: "×";
|
||||
}
|
||||
|
||||
.mentions-popup .msg-hide:hover {
|
||||
color: var(--link-color);
|
||||
}
|
||||
|
||||
.mentions-popup .hide-all-mentions {
|
||||
margin: 0;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
@media (min-height: 500px) {
|
||||
.mentions-popup {
|
||||
max-height: 60vh;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mentions-popup {
|
||||
border-radius: 0;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
width: 100%;
|
||||
max-height: none;
|
||||
right: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
top: 45px; /* header height */
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import Username from "./Username.vue";
|
||||
import ParsedMessage from "./ParsedMessage.vue";
|
||||
import socket from "../js/socket";
|
||||
import eventbus from "../js/eventbus";
|
||||
import localetime from "../js/helpers/localetime";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export default {
|
||||
name: "Mentions",
|
||||
components: {
|
||||
Username,
|
||||
ParsedMessage,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isOpen: false,
|
||||
isLoading: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
resolvedMessages() {
|
||||
const messages = this.$store.state.mentions.slice().reverse();
|
||||
|
||||
for (const message of messages) {
|
||||
message.localetime = localetime(message.time);
|
||||
message.channel = this.$store.getters.findChannel(message.chanId);
|
||||
}
|
||||
|
||||
return messages;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
"$store.state.mentions"() {
|
||||
this.isLoading = false;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
eventbus.on("mentions:toggle", this.openPopup);
|
||||
},
|
||||
destroyed() {
|
||||
eventbus.off("mentions:toggle", this.openPopup);
|
||||
},
|
||||
methods: {
|
||||
messageTime(time) {
|
||||
return dayjs(time).fromNow();
|
||||
},
|
||||
hideMention(message) {
|
||||
this.$store.state.mentions.splice(
|
||||
this.$store.state.mentions.findIndex((m) => m.msgId === message.msgId),
|
||||
1
|
||||
);
|
||||
|
||||
socket.emit("mentions:hide", message.msgId);
|
||||
},
|
||||
hideAllMentions() {
|
||||
this.$store.state.mentions = [];
|
||||
socket.emit("mentions:hide_all");
|
||||
},
|
||||
containerClick(event) {
|
||||
if (event.currentTarget === event.target) {
|
||||
this.isOpen = false;
|
||||
}
|
||||
},
|
||||
openPopup() {
|
||||
this.isOpen = !this.isOpen;
|
||||
|
||||
if (this.isOpen) {
|
||||
this.isLoading = true;
|
||||
socket.emit("mentions:get");
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
139
client/components/Message.vue
Normal file
@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<div
|
||||
:id="'msg-' + message.id"
|
||||
:class="[
|
||||
'msg',
|
||||
{self: message.self, highlight: message.highlight, 'previous-source': isPreviousSource},
|
||||
]"
|
||||
:data-type="message.type"
|
||||
:data-command="message.command"
|
||||
:data-from="message.from && message.from.nick"
|
||||
>
|
||||
<span :aria-label="messageTimeLocale" class="time tooltipped tooltipped-e"
|
||||
>{{ messageTime }}
|
||||
</span>
|
||||
<template v-if="message.type === 'unhandled'">
|
||||
<span class="from">[{{ message.command }}]</span>
|
||||
<span class="content">
|
||||
<span v-for="(param, id) in message.params" :key="id">{{ param }} </span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="isAction()">
|
||||
<span class="from"><span class="only-copy">*** </span></span>
|
||||
<component :is="messageComponent" :network="network" :message="message" />
|
||||
</template>
|
||||
<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"
|
||||
/>
|
||||
<LinkPreview
|
||||
v-for="preview in message.previews"
|
||||
:key="preview.link"
|
||||
:keep-scroll-position="keepScrollPosition"
|
||||
:link="preview"
|
||||
:channel="channel"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<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" />
|
||||
<span class="only-copy">> </span>
|
||||
</template>
|
||||
</span>
|
||||
<span v-else-if="message.type === 'plugin'" class="from">
|
||||
<template v-if="message.from && message.from.nick">
|
||||
<span class="only-copy">[</span>
|
||||
{{ message.from.nick }}
|
||||
<span class="only-copy">] </span>
|
||||
</template>
|
||||
</span>
|
||||
<span v-else class="from">
|
||||
<template v-if="message.from && message.from.nick">
|
||||
<span class="only-copy">-</span>
|
||||
<Username :user="message.from" />
|
||||
<span class="only-copy">- </span>
|
||||
</template>
|
||||
</span>
|
||||
<span class="content" dir="auto">
|
||||
<span
|
||||
v-if="message.showInActive"
|
||||
aria-label="This message was shown in your active channel"
|
||||
class="msg-shown-in-active tooltipped tooltipped-e"
|
||||
><span></span
|
||||
></span>
|
||||
<span
|
||||
v-if="message.statusmsgGroup"
|
||||
:aria-label="`This message was only shown to users with ${message.statusmsgGroup} mode`"
|
||||
class="msg-statusmsg tooltipped tooltipped-e"
|
||||
><span>{{ message.statusmsgGroup }}</span></span
|
||||
>
|
||||
<ParsedMessage :network="network" :message="message" />
|
||||
<LinkPreview
|
||||
v-for="preview in message.previews"
|
||||
:key="preview.link"
|
||||
:keep-scroll-position="keepScrollPosition"
|
||||
:link="preview"
|
||||
:channel="channel"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const constants = require("../js/constants");
|
||||
import localetime from "../js/helpers/localetime";
|
||||
import dayjs from "dayjs";
|
||||
import Username from "./Username.vue";
|
||||
import LinkPreview from "./LinkPreview.vue";
|
||||
import ParsedMessage from "./ParsedMessage.vue";
|
||||
import MessageTypes from "./MessageTypes";
|
||||
|
||||
MessageTypes.ParsedMessage = ParsedMessage;
|
||||
MessageTypes.LinkPreview = LinkPreview;
|
||||
MessageTypes.Username = Username;
|
||||
|
||||
export default {
|
||||
name: "Message",
|
||||
components: MessageTypes,
|
||||
props: {
|
||||
message: Object,
|
||||
channel: Object,
|
||||
network: Object,
|
||||
keepScrollPosition: Function,
|
||||
isPreviousSource: Boolean,
|
||||
},
|
||||
computed: {
|
||||
timeFormat() {
|
||||
let format;
|
||||
|
||||
if (this.$store.state.settings.use12hClock) {
|
||||
format = this.$store.state.settings.showSeconds ? "msg12hWithSeconds" : "msg12h";
|
||||
} else {
|
||||
format = this.$store.state.settings.showSeconds ? "msgWithSeconds" : "msgDefault";
|
||||
}
|
||||
|
||||
return constants.timeFormats[format];
|
||||
},
|
||||
messageTime() {
|
||||
return dayjs(this.message.time).format(this.timeFormat);
|
||||
},
|
||||
messageTimeLocale() {
|
||||
return localetime(this.message.time);
|
||||
},
|
||||
messageComponent() {
|
||||
return "message-" + this.message.type;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
isAction() {
|
||||
return typeof MessageTypes["message-" + this.message.type] !== "undefined";
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
116
client/components/MessageCondensed.vue
Normal file
@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<div :class="['msg', {closed: isCollapsed}]" data-type="condensed">
|
||||
<div class="condensed-summary">
|
||||
<span class="time" />
|
||||
<span class="from" />
|
||||
<span class="content" @click="onCollapseClick"
|
||||
>{{ condensedText
|
||||
}}<button class="toggle-button" aria-label="Toggle status messages"
|
||||
/></span>
|
||||
</div>
|
||||
<Message
|
||||
v-for="message in messages"
|
||||
:key="message.id"
|
||||
:network="network"
|
||||
:message="message"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const constants = require("../js/constants");
|
||||
import Message from "./Message.vue";
|
||||
|
||||
export default {
|
||||
name: "MessageCondensed",
|
||||
components: {
|
||||
Message,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
messages: Array,
|
||||
keepScrollPosition: Function,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isCollapsed: true,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
condensedText() {
|
||||
const obj = {};
|
||||
|
||||
constants.condensedTypes.forEach((type) => {
|
||||
obj[type] = 0;
|
||||
});
|
||||
|
||||
for (const message of this.messages) {
|
||||
obj[message.type]++;
|
||||
}
|
||||
|
||||
// Count quits as parts in condensed messages to reduce information density
|
||||
obj.part += obj.quit;
|
||||
|
||||
const strings = [];
|
||||
constants.condensedTypes.forEach((type) => {
|
||||
if (obj[type]) {
|
||||
switch (type) {
|
||||
case "chghost":
|
||||
strings.push(
|
||||
obj[type] +
|
||||
(obj[type] > 1
|
||||
? " users have changed hostname"
|
||||
: " user has changed hostname")
|
||||
);
|
||||
break;
|
||||
case "join":
|
||||
strings.push(
|
||||
obj[type] +
|
||||
(obj[type] > 1 ? " users have joined" : " user has joined")
|
||||
);
|
||||
break;
|
||||
case "part":
|
||||
strings.push(
|
||||
obj[type] + (obj[type] > 1 ? " users have left" : " user has left")
|
||||
);
|
||||
break;
|
||||
case "nick":
|
||||
strings.push(
|
||||
obj[type] +
|
||||
(obj[type] > 1
|
||||
? " users have changed nick"
|
||||
: " user has changed nick")
|
||||
);
|
||||
break;
|
||||
case "kick":
|
||||
strings.push(
|
||||
obj[type] +
|
||||
(obj[type] > 1 ? " users were kicked" : " user was kicked")
|
||||
);
|
||||
break;
|
||||
case "mode":
|
||||
strings.push(
|
||||
obj[type] + (obj[type] > 1 ? " modes were set" : " mode was set")
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let text = strings.pop();
|
||||
|
||||
if (strings.length) {
|
||||
text = strings.join(", ") + ", and " + text;
|
||||
}
|
||||
|
||||
return text;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onCollapseClick() {
|
||||
this.isCollapsed = !this.isCollapsed;
|
||||
this.keepScrollPosition();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
346
client/components/MessageList.vue
Normal file
@ -0,0 +1,346 @@
|
||||
<template>
|
||||
<div ref="chat" class="chat" tabindex="-1">
|
||||
<div v-show="channel.moreHistoryAvailable" class="show-more">
|
||||
<button
|
||||
ref="loadMoreButton"
|
||||
:disabled="channel.historyLoading || !$store.state.isConnected"
|
||||
class="btn"
|
||||
@click="onShowMoreClick"
|
||||
>
|
||||
<span v-if="channel.historyLoading">Loading…</span>
|
||||
<span v-else>Show older messages</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="messages"
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
aria-relevant="additions"
|
||||
@copy="onCopy"
|
||||
>
|
||||
<template v-for="(message, id) in condensedMessages">
|
||||
<DateMarker
|
||||
v-if="shouldDisplayDateMarker(message, id)"
|
||||
:key="message.id + '-date'"
|
||||
:message="message"
|
||||
/>
|
||||
<div
|
||||
v-if="shouldDisplayUnreadMarker(message.id)"
|
||||
:key="message.id + '-unread'"
|
||||
class="unread-marker"
|
||||
>
|
||||
<span class="unread-marker-text" />
|
||||
</div>
|
||||
|
||||
<MessageCondensed
|
||||
v-if="message.type === 'condensed'"
|
||||
:key="message.messages[0].id"
|
||||
:network="network"
|
||||
:keep-scroll-position="keepScrollPosition"
|
||||
:messages="message.messages"
|
||||
/>
|
||||
<Message
|
||||
v-else
|
||||
:key="message.id"
|
||||
:channel="channel"
|
||||
:network="network"
|
||||
:message="message"
|
||||
:keep-scroll-position="keepScrollPosition"
|
||||
:is-previous-source="isPreviousSource(message, id)"
|
||||
@toggle-link-preview="onLinkPreviewToggle"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const constants = require("../js/constants");
|
||||
import eventbus from "../js/eventbus";
|
||||
import clipboard from "../js/clipboard";
|
||||
import socket from "../js/socket";
|
||||
import Message from "./Message.vue";
|
||||
import MessageCondensed from "./MessageCondensed.vue";
|
||||
import DateMarker from "./DateMarker.vue";
|
||||
|
||||
let unreadMarkerShown = false;
|
||||
|
||||
export default {
|
||||
name: "MessageList",
|
||||
components: {
|
||||
Message,
|
||||
MessageCondensed,
|
||||
DateMarker,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
channel: Object,
|
||||
},
|
||||
computed: {
|
||||
condensedMessages() {
|
||||
if (this.channel.type !== "channel") {
|
||||
return this.channel.messages;
|
||||
}
|
||||
|
||||
// If actions are hidden, just return a message list with them excluded
|
||||
if (this.$store.state.settings.statusMessages === "hidden") {
|
||||
return this.channel.messages.filter(
|
||||
(message) => !constants.condensedTypes.has(message.type)
|
||||
);
|
||||
}
|
||||
|
||||
// If actions are not condensed, just return raw message list
|
||||
if (this.$store.state.settings.statusMessages !== "condensed") {
|
||||
return this.channel.messages;
|
||||
}
|
||||
|
||||
const condensed = [];
|
||||
let lastCondensedContainer = null;
|
||||
|
||||
for (const message of this.channel.messages) {
|
||||
// If this message is not condensable, or its an action affecting our user,
|
||||
// then just append the message to container and be done with it
|
||||
if (
|
||||
message.self ||
|
||||
message.highlight ||
|
||||
!constants.condensedTypes.has(message.type)
|
||||
) {
|
||||
lastCondensedContainer = null;
|
||||
|
||||
condensed.push(message);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lastCondensedContainer === null) {
|
||||
lastCondensedContainer = {
|
||||
time: message.time,
|
||||
type: "condensed",
|
||||
messages: [],
|
||||
};
|
||||
|
||||
condensed.push(lastCondensedContainer);
|
||||
}
|
||||
|
||||
lastCondensedContainer.messages.push(message);
|
||||
|
||||
// Set id of the condensed container to last message id,
|
||||
// which is required for the unread marker to work correctly
|
||||
lastCondensedContainer.id = message.id;
|
||||
|
||||
// If this message is the unread boundary, create a split condensed container
|
||||
if (message.id === this.channel.firstUnread) {
|
||||
lastCondensedContainer = null;
|
||||
}
|
||||
}
|
||||
|
||||
return condensed;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
"channel.id"() {
|
||||
this.channel.scrolledToBottom = true;
|
||||
|
||||
// Re-add the intersection observer to trigger the check again on channel switch
|
||||
// Otherwise if last channel had the button visible, switching to a new channel won't trigger the history
|
||||
if (this.historyObserver) {
|
||||
this.historyObserver.unobserve(this.$refs.loadMoreButton);
|
||||
this.historyObserver.observe(this.$refs.loadMoreButton);
|
||||
}
|
||||
},
|
||||
"channel.messages"() {
|
||||
this.keepScrollPosition();
|
||||
},
|
||||
"channel.pendingMessage"() {
|
||||
this.$nextTick(() => {
|
||||
// Keep the scroll stuck when input gets resized while typing
|
||||
this.keepScrollPosition();
|
||||
});
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.$nextTick(() => {
|
||||
if (!this.$refs.chat) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.IntersectionObserver) {
|
||||
this.historyObserver = new window.IntersectionObserver(this.onLoadButtonObserved, {
|
||||
root: this.$refs.chat,
|
||||
});
|
||||
}
|
||||
|
||||
this.jumpToBottom();
|
||||
});
|
||||
},
|
||||
mounted() {
|
||||
this.$refs.chat.addEventListener("scroll", this.handleScroll, {passive: true});
|
||||
|
||||
eventbus.on("resize", this.handleResize);
|
||||
|
||||
this.$nextTick(() => {
|
||||
if (this.historyObserver) {
|
||||
this.historyObserver.observe(this.$refs.loadMoreButton);
|
||||
}
|
||||
});
|
||||
},
|
||||
beforeUpdate() {
|
||||
unreadMarkerShown = false;
|
||||
},
|
||||
beforeDestroy() {
|
||||
eventbus.off("resize", this.handleResize);
|
||||
this.$refs.chat.removeEventListener("scroll", this.handleScroll);
|
||||
},
|
||||
destroyed() {
|
||||
if (this.historyObserver) {
|
||||
this.historyObserver.disconnect();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
shouldDisplayDateMarker(message, id) {
|
||||
const previousMessage = this.condensedMessages[id - 1];
|
||||
|
||||
if (!previousMessage) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const oldDate = new Date(previousMessage.time);
|
||||
const newDate = new Date(message.time);
|
||||
|
||||
return (
|
||||
oldDate.getDate() !== newDate.getDate() ||
|
||||
oldDate.getMonth() !== newDate.getMonth() ||
|
||||
oldDate.getFullYear() !== newDate.getFullYear()
|
||||
);
|
||||
},
|
||||
shouldDisplayUnreadMarker(id) {
|
||||
if (!unreadMarkerShown && id > this.channel.firstUnread) {
|
||||
unreadMarkerShown = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
isPreviousSource(currentMessage, id) {
|
||||
const previousMessage = this.condensedMessages[id - 1];
|
||||
return (
|
||||
previousMessage &&
|
||||
currentMessage.type === "message" &&
|
||||
previousMessage.type === "message" &&
|
||||
previousMessage.from &&
|
||||
currentMessage.from.nick === previousMessage.from.nick
|
||||
);
|
||||
},
|
||||
onCopy() {
|
||||
clipboard(this.$el);
|
||||
},
|
||||
onLinkPreviewToggle(preview, message) {
|
||||
this.keepScrollPosition();
|
||||
|
||||
// Tell the server we're toggling so it remembers at page reload
|
||||
socket.emit("msg:preview:toggle", {
|
||||
target: this.channel.id,
|
||||
msgId: message.id,
|
||||
link: preview.link,
|
||||
shown: preview.shown,
|
||||
});
|
||||
},
|
||||
onShowMoreClick() {
|
||||
if (!this.$store.state.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
let lastMessage = -1;
|
||||
|
||||
// Find the id of first message that isn't showInActive
|
||||
// If showInActive is set, this message is actually in another channel
|
||||
for (const message of this.channel.messages) {
|
||||
if (!message.showInActive) {
|
||||
lastMessage = message.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.channel.historyLoading = true;
|
||||
|
||||
socket.emit("more", {
|
||||
target: this.channel.id,
|
||||
lastId: lastMessage,
|
||||
condensed: this.$store.state.settings.statusMessages !== "shown",
|
||||
});
|
||||
},
|
||||
onLoadButtonObserved(entries) {
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.isIntersecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.onShowMoreClick();
|
||||
});
|
||||
},
|
||||
keepScrollPosition() {
|
||||
// If we are already waiting for the next tick to force scroll position,
|
||||
// we have no reason to perform more checks and set it again in the next tick
|
||||
if (this.isWaitingForNextTick) {
|
||||
return;
|
||||
}
|
||||
|
||||
const el = this.$refs.chat;
|
||||
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.channel.scrolledToBottom) {
|
||||
if (this.channel.historyLoading) {
|
||||
const heightOld = el.scrollHeight - el.scrollTop;
|
||||
|
||||
this.isWaitingForNextTick = true;
|
||||
this.$nextTick(() => {
|
||||
this.isWaitingForNextTick = false;
|
||||
this.skipNextScrollEvent = true;
|
||||
el.scrollTop = el.scrollHeight - heightOld;
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.isWaitingForNextTick = true;
|
||||
this.$nextTick(() => {
|
||||
this.isWaitingForNextTick = false;
|
||||
this.jumpToBottom();
|
||||
});
|
||||
},
|
||||
handleScroll() {
|
||||
// Setting scrollTop also triggers scroll event
|
||||
// We don't want to perform calculations for that
|
||||
if (this.skipNextScrollEvent) {
|
||||
this.skipNextScrollEvent = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const el = this.$refs.chat;
|
||||
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.channel.scrolledToBottom = el.scrollHeight - el.scrollTop - el.offsetHeight <= 30;
|
||||
},
|
||||
handleResize() {
|
||||
// Keep message list scrolled to bottom on resize
|
||||
if (this.channel.scrolledToBottom) {
|
||||
this.jumpToBottom();
|
||||
}
|
||||
},
|
||||
jumpToBottom() {
|
||||
this.skipNextScrollEvent = true;
|
||||
this.channel.scrolledToBottom = true;
|
||||
|
||||
const el = this.$refs.chat;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
27
client/components/MessageTypes/away.vue
Normal file
@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<span class="content">
|
||||
<ParsedMessage v-if="message.self" :network="network" :message="message" />
|
||||
<template v-else>
|
||||
<Username :user="message.from" />
|
||||
is away
|
||||
<i class="away-message">(<ParsedMessage :network="network" :message="message" />)</i>
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
name: "MessageTypeAway",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
},
|
||||
};
|
||||
</script>
|
26
client/components/MessageTypes/back.vue
Normal file
@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<span class="content">
|
||||
<ParsedMessage v-if="message.self" :network="network" :message="message" />
|
||||
<template v-else>
|
||||
<Username :user="message.from" />
|
||||
is back
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
name: "MessageTypeBack",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
},
|
||||
};
|
||||
</script>
|
27
client/components/MessageTypes/chghost.vue
Normal file
@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<span class="content">
|
||||
<Username :user="message.from" />
|
||||
has changed
|
||||
<span v-if="message.new_ident"
|
||||
>username to <b>{{ message.new_ident }}</b></span
|
||||
>
|
||||
<span v-if="message.new_host"
|
||||
>hostname to <i class="hostmask">{{ message.new_host }}</i></span
|
||||
>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
name: "MessageTypeChangeHost",
|
||||
components: {
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
},
|
||||
};
|
||||
</script>
|
23
client/components/MessageTypes/ctcp.vue
Normal file
@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<span class="content">
|
||||
<Username :user="message.from" /> 
|
||||
<span class="ctcp-message"><ParsedMessage :text="message.ctcpMessage" /></span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
name: "MessageTypeCTCP",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
},
|
||||
};
|
||||
</script>
|
24
client/components/MessageTypes/ctcp_request.vue
Normal file
@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<span class="content">
|
||||
<Username :user="message.from" />
|
||||
sent a <abbr title="Client-to-client protocol">CTCP</abbr> request:
|
||||
<span class="ctcp-message"><ParsedMessage :text="message.ctcpMessage" /></span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
name: "MessageTypeRequestCTCP",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
},
|
||||
};
|
||||
</script>
|
58
client/components/MessageTypes/error.vue
Normal file
@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<span class="content">
|
||||
<ParsedMessage :network="network" :message="message" :text="errorMessage" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
|
||||
export default {
|
||||
name: "MessageTypeError",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
},
|
||||
computed: {
|
||||
errorMessage() {
|
||||
switch (this.message.error) {
|
||||
case "bad_channel_key":
|
||||
return `Cannot join ${this.message.channel} - Bad channel key.`;
|
||||
case "banned_from_channel":
|
||||
return `Cannot join ${this.message.channel} - You have been banned from the channel.`;
|
||||
case "cannot_send_to_channel":
|
||||
return `Cannot send to channel ${this.message.channel}`;
|
||||
case "channel_is_full":
|
||||
return `Cannot join ${this.message.channel} - Channel is full.`;
|
||||
case "chanop_privs_needed":
|
||||
return "Cannot perform action: You're not a channel operator.";
|
||||
case "invite_only_channel":
|
||||
return `Cannot join ${this.message.channel} - Channel is invite only.`;
|
||||
case "no_such_nick":
|
||||
return `User ${this.message.nick} hasn't logged in or does not exist.`;
|
||||
case "not_on_channel":
|
||||
return "Cannot perform action: You're not on the channel.";
|
||||
case "password_mismatch":
|
||||
return "Password mismatch.";
|
||||
case "too_many_channels":
|
||||
return `Cannot join ${this.message.channel} - You've already reached the maximum number of channels allowed.`;
|
||||
case "unknown_command":
|
||||
return `Unknown command: ${this.message.command}`;
|
||||
case "user_not_in_channel":
|
||||
return `User ${this.message.nick} is not on the channel.`;
|
||||
case "user_on_channel":
|
||||
return `User ${this.message.nick} is already on the channel.`;
|
||||
default:
|
||||
if (this.message.reason) {
|
||||
return `${this.message.reason} (${this.message.error})`;
|
||||
}
|
||||
|
||||
return this.message.error;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
13
client/components/MessageTypes/index.js
Normal file
@ -0,0 +1,13 @@
|
||||
"use strict";
|
||||
|
||||
// This creates a version of `require()` in the context of the current
|
||||
// directory, so we iterate over its content, which is a map statically built by
|
||||
// Webpack.
|
||||
// Second argument says it's recursive, third makes sure we only load templates.
|
||||
const requireViews = require.context(".", false, /\.vue$/);
|
||||
|
||||
export default requireViews.keys().reduce((acc, path) => {
|
||||
acc["message-" + path.substring(2, path.length - 4)] = requireViews(path).default;
|
||||
|
||||
return acc;
|
||||
}, {});
|
26
client/components/MessageTypes/invite.vue
Normal file
@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<span class="content">
|
||||
<Username :user="message.from" />
|
||||
invited
|
||||
<span v-if="message.invitedYou">you</span>
|
||||
<Username v-else :user="message.target" />
|
||||
to <ParsedMessage :network="network" :text="message.channel" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
name: "MessageTypeInvite",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
},
|
||||
};
|
||||
</script>
|
22
client/components/MessageTypes/join.vue
Normal file
@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<span class="content">
|
||||
<Username :user="message.from" />
|
||||
<i class="hostmask"> ({{ message.hostmask }})</i>
|
||||
has joined the channel
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
name: "MessageTypeJoin",
|
||||
components: {
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
},
|
||||
};
|
||||
</script>
|
27
client/components/MessageTypes/kick.vue
Normal file
@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<span class="content">
|
||||
<Username :user="message.from" />
|
||||
has kicked
|
||||
<Username :user="message.target" />
|
||||
<i v-if="message.text" class="part-reason"
|
||||
> (<ParsedMessage :network="network" :message="message" />)</i
|
||||
>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
name: "MessageTypeKick",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
},
|
||||
};
|
||||
</script>
|
24
client/components/MessageTypes/mode.vue
Normal file
@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<span class="content">
|
||||
<Username :user="message.from" />
|
||||
sets mode
|
||||
<ParsedMessage :message="message" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
name: "MessageTypeMode",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
},
|
||||
};
|
||||
</script>
|
15
client/components/MessageTypes/mode_channel.vue
Normal file
@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<span class="content">
|
||||
Channel mode is <b>{{ message.text }}</b>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "MessageChannelMode",
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
},
|
||||
};
|
||||
</script>
|
37
client/components/MessageTypes/monospace_block.vue
Normal file
@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<span class="content">
|
||||
<span class="text"><ParsedMessage :network="network" :text="cleanText" /></span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
|
||||
export default {
|
||||
name: "MessageTypeMonospaceBlock",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
},
|
||||
computed: {
|
||||
cleanText() {
|
||||
let lines = this.message.text.split("\n");
|
||||
|
||||
// If all non-empty lines of the MOTD start with a hyphen (which is common
|
||||
// across MOTDs), remove all the leading hyphens.
|
||||
if (lines.every((line) => line === "" || line[0] === "-")) {
|
||||
lines = lines.map((line) => line.substr(2));
|
||||
}
|
||||
|
||||
// Remove empty lines around the MOTD (but not within it)
|
||||
return lines
|
||||
.map((line) => line.replace(/\s*$/, ""))
|
||||
.join("\n")
|
||||
.replace(/^[\r\n]+|[\r\n]+$/g, "");
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
22
client/components/MessageTypes/nick.vue
Normal file
@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<span class="content">
|
||||
<Username :user="message.from" />
|
||||
is now known as
|
||||
<Username :user="{nick: message.new_nick, mode: message.from.mode}" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
name: "MessageTypeNick",
|
||||
components: {
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
},
|
||||
};
|
||||
</script>
|
26
client/components/MessageTypes/part.vue
Normal file
@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<span class="content">
|
||||
<Username :user="message.from" />
|
||||
<i class="hostmask"> ({{ message.hostmask }})</i> has left the channel
|
||||
<i v-if="message.text" class="part-reason"
|
||||
>(<ParsedMessage :network="network" :message="message" />)</i
|
||||
>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
name: "MessageTypePart",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
},
|
||||
};
|
||||
</script>
|
26
client/components/MessageTypes/quit.vue
Normal file
@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<span class="content">
|
||||
<Username :user="message.from" />
|
||||
<i class="hostmask"> ({{ message.hostmask }})</i> has quit
|
||||
<i v-if="message.text" class="quit-reason"
|
||||
>(<ParsedMessage :network="network" :message="message" />)</i
|
||||
>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
name: "MessageTypeQuit",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
},
|
||||
};
|
||||
</script>
|
13
client/components/MessageTypes/raw.vue
Normal file
@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<span class="content">{{ message.text }}</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "MessageTypeRaw",
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
},
|
||||
};
|
||||
</script>
|
28
client/components/MessageTypes/topic.vue
Normal file
@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<span class="content">
|
||||
<template v-if="message.from && message.from.nick"
|
||||
><Username :user="message.from" /> has changed the topic to:
|
||||
</template>
|
||||
<template v-else>The topic is: </template>
|
||||
<span v-if="message.text" class="new-topic"
|
||||
><ParsedMessage :network="network" :message="message"
|
||||
/></span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
name: "MessageTypeTopic",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
},
|
||||
};
|
||||
</script>
|
28
client/components/MessageTypes/topic_set_by.vue
Normal file
@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<span class="content">
|
||||
Topic set by
|
||||
<Username :user="message.from" />
|
||||
on {{ messageTimeLocale }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import localetime from "../../js/helpers/localetime";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
name: "MessageTypeTopicSetBy",
|
||||
components: {
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
},
|
||||
computed: {
|
||||
messageTimeLocale() {
|
||||
return localetime(this.message.when);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
130
client/components/MessageTypes/whois.vue
Normal file
@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<span class="content">
|
||||
<p>
|
||||
<Username :user="{nick: message.whois.nick}" />
|
||||
<span v-if="message.whois.whowas"> is offline, last information:</span>
|
||||
</p>
|
||||
|
||||
<dl class="whois">
|
||||
<template v-if="message.whois.account">
|
||||
<dt>Logged in as:</dt>
|
||||
<dd>{{ message.whois.account }}</dd>
|
||||
</template>
|
||||
|
||||
<dt>Host mask:</dt>
|
||||
<dd class="hostmask">{{ message.whois.ident }}@{{ message.whois.hostname }}</dd>
|
||||
|
||||
<template v-if="message.whois.actual_hostname">
|
||||
<dt>Actual host:</dt>
|
||||
<dd class="hostmask">
|
||||
<a
|
||||
:href="'https://ipinfo.io/' + message.whois.actual_ip"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>{{ message.whois.actual_ip }}</a
|
||||
>
|
||||
<i v-if="message.whois.actual_hostname != message.whois.actual_ip">
|
||||
({{ message.whois.actual_hostname }})</i
|
||||
>
|
||||
</dd>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.real_name">
|
||||
<dt>Real name:</dt>
|
||||
<dd><ParsedMessage :network="network" :text="message.whois.real_name" /></dd>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.registered_nick">
|
||||
<dt>Registered nick:</dt>
|
||||
<dd>{{ message.whois.registered_nick }}</dd>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.channels">
|
||||
<dt>Channels:</dt>
|
||||
<dd><ParsedMessage :network="network" :text="message.whois.channels" /></dd>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.modes">
|
||||
<dt>Modes:</dt>
|
||||
<dd>{{ message.whois.modes }}</dd>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.special">
|
||||
<template v-for="special in message.whois.special">
|
||||
<dt :key="special">Special:</dt>
|
||||
<dd :key="special">{{ special }}</dd>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.operator">
|
||||
<dt>Operator:</dt>
|
||||
<dd>{{ message.whois.operator }}</dd>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.helpop">
|
||||
<dt>Available for help:</dt>
|
||||
<dd>Yes</dd>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.bot">
|
||||
<dt>Is a bot:</dt>
|
||||
<dd>Yes</dd>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.away">
|
||||
<dt>Away:</dt>
|
||||
<dd><ParsedMessage :network="network" :text="message.whois.away" /></dd>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.secure">
|
||||
<dt>Secure connection:</dt>
|
||||
<dd>Yes</dd>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.certfp">
|
||||
<dt>Certificate:</dt>
|
||||
<dd>{{ message.whois.certfp }}</dd>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.server">
|
||||
<dt>Connected to:</dt>
|
||||
<dd>
|
||||
{{ message.whois.server }} <i>({{ message.whois.server_info }})</i>
|
||||
</dd>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.logonTime">
|
||||
<dt>Connected at:</dt>
|
||||
<dd>{{ localetime(message.whois.logonTime) }}</dd>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.idle">
|
||||
<dt>Idle since:</dt>
|
||||
<dd>{{ localetime(message.whois.idleTime) }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import localetime from "../../js/helpers/localetime";
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
name: "MessageTypeWhois",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
},
|
||||
methods: {
|
||||
localetime(date) {
|
||||
return localetime(date);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
437
client/components/NetworkForm.vue
Normal file
@ -0,0 +1,437 @@
|
||||
<template>
|
||||
<div id="connect" class="window" role="tabpanel" aria-label="Connect">
|
||||
<div class="header">
|
||||
<SidebarToggle />
|
||||
</div>
|
||||
<form class="container" method="post" action="" @submit.prevent="onSubmit">
|
||||
<h1 class="title">
|
||||
<template v-if="defaults.uuid">
|
||||
<input v-model="defaults.uuid" type="hidden" name="uuid" />
|
||||
Edit {{ defaults.name }}
|
||||
</template>
|
||||
<template v-else>
|
||||
Welcome
|
||||
<template v-if="config.lockNetwork && $store.state.serverConfiguration.public">
|
||||
to the {{ defaults.name }} Web Chat
|
||||
</template>
|
||||
</template>
|
||||
</h1>
|
||||
<template v-if="!config.lockNetwork">
|
||||
<h2>Network settings</h2>
|
||||
<div class="connect-row">
|
||||
<label for="connect:name">Name</label>
|
||||
<input
|
||||
id="connect:name"
|
||||
v-model="defaults.name"
|
||||
class="input"
|
||||
name="name"
|
||||
maxlength="100"
|
||||
/>
|
||||
</div>
|
||||
<div class="connect-row">
|
||||
<label for="connect:host">Server</label>
|
||||
<div class="input-wrap">
|
||||
<input
|
||||
id="connect:host"
|
||||
v-model="defaults.host"
|
||||
class="input"
|
||||
name="host"
|
||||
aria-label="Server address"
|
||||
maxlength="255"
|
||||
required
|
||||
/>
|
||||
<span id="connect:portseparator">:</span>
|
||||
<input
|
||||
id="connect:port"
|
||||
v-model="defaults.port"
|
||||
class="input"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
name="port"
|
||||
aria-label="Server port"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="connect-row">
|
||||
<label for="connect:password">Password</label>
|
||||
<RevealPassword
|
||||
v-slot:default="slotProps"
|
||||
class="input-wrap password-container"
|
||||
>
|
||||
<input
|
||||
id="connect:password"
|
||||
v-model="defaults.password"
|
||||
class="input"
|
||||
:type="slotProps.isVisible ? 'text' : 'password'"
|
||||
placeholder="Server password (optional)"
|
||||
name="password"
|
||||
maxlength="300"
|
||||
/>
|
||||
</RevealPassword>
|
||||
</div>
|
||||
<div class="connect-row">
|
||||
<label></label>
|
||||
<div class="input-wrap">
|
||||
<label class="tls">
|
||||
<input
|
||||
v-model="defaults.tls"
|
||||
type="checkbox"
|
||||
name="tls"
|
||||
:disabled="defaults.hasSTSPolicy"
|
||||
/>
|
||||
Use secure connection (TLS)
|
||||
<span
|
||||
v-if="defaults.hasSTSPolicy"
|
||||
class="tooltipped tooltipped-n tooltipped-no-delay"
|
||||
aria-label="This network has a strict transport security policy, you will be unable to disable TLS"
|
||||
>🔒 STS</span
|
||||
>
|
||||
</label>
|
||||
<label class="tls">
|
||||
<input
|
||||
v-model="defaults.rejectUnauthorized"
|
||||
type="checkbox"
|
||||
name="rejectUnauthorized"
|
||||
/>
|
||||
Only allow trusted certificates
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="config.lockNetwork && !$store.state.serverConfiguration.public">
|
||||
<h2>Network settings</h2>
|
||||
<div class="connect-row">
|
||||
<label for="connect:name">Name</label>
|
||||
<input
|
||||
id="connect:name"
|
||||
v-model="defaults.name"
|
||||
class="input"
|
||||
name="name"
|
||||
maxlength="100"
|
||||
/>
|
||||
</div>
|
||||
<div class="connect-row">
|
||||
<label for="connect:password">Password</label>
|
||||
<RevealPassword
|
||||
v-slot:default="slotProps"
|
||||
class="input-wrap password-container"
|
||||
>
|
||||
<input
|
||||
id="connect:password"
|
||||
v-model="defaults.password"
|
||||
class="input"
|
||||
:type="slotProps.isVisible ? 'text' : 'password'"
|
||||
placeholder="Server password (optional)"
|
||||
name="password"
|
||||
maxlength="300"
|
||||
/>
|
||||
</RevealPassword>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<h2></h2>
|
||||
<div class="connect-row">
|
||||
<label for="connect:nick">Nickname</label>
|
||||
<input
|
||||
id="connect:nick"
|
||||
v-model="defaults.nick"
|
||||
class="input nick"
|
||||
name="nick"
|
||||
pattern="[^\s:!@]+"
|
||||
maxlength="100"
|
||||
required
|
||||
@input="onNickChanged"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="!config.useHexIp">
|
||||
<div class="connect-row">
|
||||
<label for="connect:username">Username</label>
|
||||
<input
|
||||
id="connect:username"
|
||||
ref="usernameInput"
|
||||
v-model="defaults.username"
|
||||
class="input username"
|
||||
name="username"
|
||||
maxlength="100"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<!--div class="connect-row">
|
||||
<label for="connect:realname">Real name</label>
|
||||
<input
|
||||
id="connect:realname"
|
||||
v-model="defaults.realname"
|
||||
class="input"
|
||||
name="realname"
|
||||
maxlength="300"
|
||||
/>
|
||||
</div-->
|
||||
<template v-if="defaults.uuid && !$store.state.serverConfiguration.public">
|
||||
<div class="connect-row">
|
||||
<label for="connect:commands">
|
||||
Commands
|
||||
<span
|
||||
class="tooltipped tooltipped-ne tooltipped-no-delay"
|
||||
aria-label="One /command per line.
|
||||
Each command will be executed in
|
||||
the server tab on new connection"
|
||||
>
|
||||
<button class="extra-help" />
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="connect:commands"
|
||||
ref="commandsInput"
|
||||
:value="defaults.commands ? defaults.commands.join('\n') : ''"
|
||||
class="input"
|
||||
name="commands"
|
||||
@input="resizeCommandsInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="!defaults.uuid">
|
||||
<div class="connect-row">
|
||||
<label for="connect:channels">Rooms</label>
|
||||
<input
|
||||
id="connect:channels"
|
||||
v-model="defaults.join"
|
||||
class="input"
|
||||
name="join"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="$store.state.serverConfiguration.public">
|
||||
<template v-if="config.lockNetwork">
|
||||
<div class="connect-row">
|
||||
<label></label>
|
||||
<div class="input-wrap">
|
||||
<label class="tls">
|
||||
<input v-model="displayPasswordField" type="checkbox" />
|
||||
I am already registered
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="displayPasswordField" class="connect-row">
|
||||
<label for="connect:password">Password</label>
|
||||
<RevealPassword
|
||||
v-slot:default="slotProps"
|
||||
class="input-wrap password-container"
|
||||
>
|
||||
<input
|
||||
id="connect:password"
|
||||
ref="publicPassword"
|
||||
v-model="defaults.password"
|
||||
class="input"
|
||||
:type="slotProps.isVisible ? 'text' : 'password'"
|
||||
placeholder="Your Passwort Goes here"
|
||||
name="password"
|
||||
maxlength="300"
|
||||
/>
|
||||
</RevealPassword>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<h2 id="label-auth">Authentication</h2>
|
||||
<div class="connect-row connect-auth" role="group" aria-labelledby="label-auth">
|
||||
<label class="opt">
|
||||
<input
|
||||
:checked="!defaults.sasl"
|
||||
type="radio"
|
||||
name="sasl"
|
||||
value=""
|
||||
@change="setSaslAuth('')"
|
||||
/>
|
||||
No authentication
|
||||
</label>
|
||||
<label class="opt">
|
||||
<input
|
||||
:checked="defaults.sasl === 'plain'"
|
||||
type="radio"
|
||||
name="sasl"
|
||||
value="plain"
|
||||
@change="setSaslAuth('plain')"
|
||||
/>
|
||||
Username + password (SASL PLAIN)
|
||||
</label>
|
||||
<label
|
||||
v-if="!$store.state.serverConfiguration.public && defaults.tls"
|
||||
class="opt"
|
||||
>
|
||||
<input
|
||||
:checked="defaults.sasl === 'external'"
|
||||
type="radio"
|
||||
name="sasl"
|
||||
value="external"
|
||||
@change="setSaslAuth('external')"
|
||||
/>
|
||||
Client certificate (SASL EXTERNAL)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<template v-if="defaults.sasl === 'plain'">
|
||||
<div class="connect-row">
|
||||
<label for="connect:username">Account</label>
|
||||
<input
|
||||
id="connect:saslAccount"
|
||||
v-model="defaults.saslAccount"
|
||||
class="input"
|
||||
name="saslAccount"
|
||||
maxlength="100"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="connect-row">
|
||||
<label for="connect:password">Password</label>
|
||||
<RevealPassword
|
||||
v-slot:default="slotProps"
|
||||
class="input-wrap password-container"
|
||||
>
|
||||
<input
|
||||
id="connect:saslPassword"
|
||||
v-model="defaults.saslPassword"
|
||||
class="input"
|
||||
:type="slotProps.isVisible ? 'text' : 'password'"
|
||||
name="saslPassword"
|
||||
maxlength="300"
|
||||
required
|
||||
/>
|
||||
</RevealPassword>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="defaults.sasl === 'external'" class="connect-sasl-external">
|
||||
<p>The Lounge automatically generates and manages the client certificate.</p>
|
||||
<p>
|
||||
On the IRC server, you will need to tell the services to attach the
|
||||
certificate fingerprint (certfp) to your account, for example:
|
||||
</p>
|
||||
<pre><code>/msg NickServ CERT ADD</code></pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<button type="submit" class="btn" :disabled="disabled ? true : false">
|
||||
<template v-if="defaults.uuid">Save network</template>
|
||||
<template v-else>Connect</template>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
#connect .connect-auth {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
#connect .connect-auth .opt {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#connect .connect-auth input {
|
||||
margin: 3px 10px 0 0;
|
||||
}
|
||||
|
||||
#connect .connect-sasl-external {
|
||||
padding: 10px;
|
||||
border-radius: 2px;
|
||||
background-color: #d9edf7;
|
||||
color: #31708f;
|
||||
}
|
||||
|
||||
#connect .connect-sasl-external pre {
|
||||
margin: 0;
|
||||
user-select: text;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import RevealPassword from "./RevealPassword.vue";
|
||||
import SidebarToggle from "./SidebarToggle.vue";
|
||||
|
||||
export default {
|
||||
name: "NetworkForm",
|
||||
components: {
|
||||
RevealPassword,
|
||||
SidebarToggle,
|
||||
},
|
||||
props: {
|
||||
handleSubmit: Function,
|
||||
defaults: Object,
|
||||
disabled: Boolean,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
config: this.$store.state.serverConfiguration,
|
||||
previousUsername: this.defaults.username,
|
||||
displayPasswordField: false,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
displayPasswordField(value) {
|
||||
if (value) {
|
||||
this.$nextTick(() => this.$refs.publicPassword.focus());
|
||||
}
|
||||
},
|
||||
"defaults.commands"() {
|
||||
this.$nextTick(this.resizeCommandsInput);
|
||||
},
|
||||
"defaults.tls"(isSecureChecked) {
|
||||
const ports = [6667, 6697];
|
||||
const newPort = isSecureChecked ? 0 : 1;
|
||||
|
||||
// If you disable TLS and current port is 6697,
|
||||
// set it to 6667, and vice versa
|
||||
if (this.defaults.port === ports[newPort]) {
|
||||
this.defaults.port = ports[1 - newPort];
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
setSaslAuth(type) {
|
||||
this.defaults.sasl = type;
|
||||
},
|
||||
onNickChanged(event) {
|
||||
// Username input is not available when useHexIp is set
|
||||
if (!this.$refs.usernameInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!this.$refs.usernameInput.value ||
|
||||
this.$refs.usernameInput.value === this.previousUsername
|
||||
) {
|
||||
this.$refs.usernameInput.value = event.target.value;
|
||||
}
|
||||
|
||||
this.previousUsername = event.target.value;
|
||||
},
|
||||
onSubmit(event) {
|
||||
const formData = new FormData(event.target);
|
||||
const data = {};
|
||||
|
||||
for (const item of formData.entries()) {
|
||||
data[item[0]] = item[1];
|
||||
}
|
||||
|
||||
this.handleSubmit(data);
|
||||
},
|
||||
resizeCommandsInput() {
|
||||
if (!this.$refs.commandsInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset height first so it can down size
|
||||
this.$refs.commandsInput.style.height = "";
|
||||
|
||||
// 2 pixels to account for the border
|
||||
this.$refs.commandsInput.style.height =
|
||||
Math.ceil(this.$refs.commandsInput.scrollHeight + 2) + "px";
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
423
client/components/NetworkList.vue
Normal file
@ -0,0 +1,423 @@
|
||||
<template>
|
||||
<div v-if="$store.state.networks.length === 0" class="empty">
|
||||
You are not connected to any networks yet.
|
||||
</div>
|
||||
<div v-else ref="networklist">
|
||||
<div class="jump-to-input">
|
||||
<input
|
||||
ref="searchInput"
|
||||
:value="searchText"
|
||||
placeholder="Jump to..."
|
||||
type="search"
|
||||
class="search input mousetrap"
|
||||
aria-label="Search among the channel list"
|
||||
tabindex="-1"
|
||||
@input="setSearchText"
|
||||
@keydown.up="navigateResults($event, -1)"
|
||||
@keydown.down="navigateResults($event, 1)"
|
||||
@keydown.page-up="navigateResults($event, -10)"
|
||||
@keydown.page-down="navigateResults($event, 10)"
|
||||
@keydown.enter="selectResult"
|
||||
@keydown.escape="deactivateSearch"
|
||||
@focus="activateSearch"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="searchText" class="jump-to-results">
|
||||
<div v-if="results.length">
|
||||
<div
|
||||
v-for="item in results"
|
||||
:key="item.channel.id"
|
||||
@mouseenter="setActiveSearchItem(item.channel)"
|
||||
@click.prevent="selectResult"
|
||||
>
|
||||
<Channel
|
||||
v-if="item.channel.type !== 'lobby'"
|
||||
:channel="item.channel"
|
||||
:network="item.network"
|
||||
:active="item.channel === activeSearchItem"
|
||||
:is-filtering="true"
|
||||
/>
|
||||
<NetworkLobby
|
||||
v-else
|
||||
:channel="item.channel"
|
||||
:network="item.network"
|
||||
:active="item.channel === activeSearchItem"
|
||||
:is-filtering="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-results">No results found.</div>
|
||||
</div>
|
||||
<Draggable
|
||||
v-else
|
||||
:list="$store.state.networks"
|
||||
:filter="isCurrentlyInTouch"
|
||||
:prevent-on-filter="false"
|
||||
handle=".channel-list-item[data-type='lobby']"
|
||||
draggable=".network"
|
||||
ghost-class="ui-sortable-ghost"
|
||||
drag-class="ui-sortable-dragged"
|
||||
group="networks"
|
||||
class="networks"
|
||||
@change="onNetworkSort"
|
||||
@start="onDragStart"
|
||||
@end="onDragEnd"
|
||||
>
|
||||
<div
|
||||
v-for="network in $store.state.networks"
|
||||
:id="'network-' + network.uuid"
|
||||
:key="network.uuid"
|
||||
:class="{
|
||||
collapsed: network.isCollapsed,
|
||||
'not-connected': !network.status.connected,
|
||||
'not-secure': !network.status.secure,
|
||||
}"
|
||||
class="network"
|
||||
role="region"
|
||||
>
|
||||
<NetworkLobby
|
||||
:network="network"
|
||||
:is-join-channel-shown="network.isJoinChannelShown"
|
||||
:active="
|
||||
$store.state.activeChannel &&
|
||||
network.channels[0] === $store.state.activeChannel.channel
|
||||
"
|
||||
@toggle-join-channel="network.isJoinChannelShown = !network.isJoinChannelShown"
|
||||
/>
|
||||
<JoinChannel
|
||||
v-if="network.isJoinChannelShown"
|
||||
:network="network"
|
||||
:channel="network.channels[0]"
|
||||
@toggle-join-channel="network.isJoinChannelShown = !network.isJoinChannelShown"
|
||||
/>
|
||||
|
||||
<Draggable
|
||||
draggable=".channel-list-item"
|
||||
ghost-class="ui-sortable-ghost"
|
||||
drag-class="ui-sortable-dragged"
|
||||
:group="network.uuid"
|
||||
:filter="isCurrentlyInTouch"
|
||||
:prevent-on-filter="false"
|
||||
:list="network.channels"
|
||||
class="channels"
|
||||
@change="onChannelSort"
|
||||
@start="onDragStart"
|
||||
@end="onDragEnd"
|
||||
>
|
||||
<template v-for="(channel, index) in network.channels">
|
||||
<Channel
|
||||
v-if="index > 0"
|
||||
:key="channel.id"
|
||||
:channel="channel"
|
||||
:network="network"
|
||||
:active="
|
||||
$store.state.activeChannel &&
|
||||
channel === $store.state.activeChannel.channel
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</Draggable>
|
||||
</div>
|
||||
</Draggable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.jump-to-input {
|
||||
margin: 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.jump-to-input .input {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
color: #fff;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
padding-right: 35px;
|
||||
}
|
||||
|
||||
.jump-to-input .input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.jump-to-input::before {
|
||||
content: "\f002"; /* http://fontawesome.io/icon/search/ */
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
line-height: 35px !important;
|
||||
}
|
||||
|
||||
.jump-to-results {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.jump-to-results .no-results {
|
||||
margin: 14px 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.jump-to-results .channel-list-item.active {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.jump-to-results .channel-list-item .add-channel,
|
||||
.jump-to-results .channel-list-item .close-tooltip {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.jump-to-results .channel-list-item[data-type="lobby"] {
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
.jump-to-results .channel-list-item[data-type="lobby"]::before {
|
||||
content: "\f233";
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import Mousetrap from "mousetrap";
|
||||
import Draggable from "vuedraggable";
|
||||
import {filter as fuzzyFilter} from "fuzzy";
|
||||
import NetworkLobby from "./NetworkLobby.vue";
|
||||
import Channel from "./Channel.vue";
|
||||
import JoinChannel from "./JoinChannel.vue";
|
||||
|
||||
import socket from "../js/socket";
|
||||
import collapseNetwork from "../js/helpers/collapseNetwork";
|
||||
import isIgnoredKeybind from "../js/helpers/isIgnoredKeybind";
|
||||
|
||||
export default {
|
||||
name: "NetworkList",
|
||||
components: {
|
||||
JoinChannel,
|
||||
NetworkLobby,
|
||||
Channel,
|
||||
Draggable,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
searchText: "",
|
||||
activeSearchItem: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
items() {
|
||||
const items = [];
|
||||
|
||||
for (const network of this.$store.state.networks) {
|
||||
for (const channel of network.channels) {
|
||||
if (
|
||||
this.$store.state.activeChannel &&
|
||||
channel === this.$store.state.activeChannel.channel
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
items.push({network, channel});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
},
|
||||
results() {
|
||||
const results = fuzzyFilter(this.searchText, this.items, {
|
||||
extract: (item) => item.channel.name,
|
||||
}).map((item) => item.original);
|
||||
|
||||
return results;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
searchText() {
|
||||
this.setActiveSearchItem();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
Mousetrap.bind("alt+shift+right", this.expandNetwork);
|
||||
Mousetrap.bind("alt+shift+left", this.collapseNetwork);
|
||||
Mousetrap.bind("alt+j", this.toggleSearch);
|
||||
},
|
||||
beforeDestroy() {
|
||||
Mousetrap.unbind("alt+shift+right", this.expandNetwork);
|
||||
Mousetrap.unbind("alt+shift+left", this.collapseNetwork);
|
||||
Mousetrap.unbind("alt+j", this.toggleSearch);
|
||||
},
|
||||
methods: {
|
||||
expandNetwork(event) {
|
||||
if (isIgnoredKeybind(event)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.$store.state.activeChannel) {
|
||||
collapseNetwork(this.$store.state.activeChannel.network, false);
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
collapseNetwork(event) {
|
||||
if (isIgnoredKeybind(event)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.$store.state.activeChannel) {
|
||||
collapseNetwork(this.$store.state.activeChannel.network, true);
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
isCurrentlyInTouch(e) {
|
||||
// TODO: Implement a way to sort on touch devices
|
||||
return e.pointerType !== "mouse";
|
||||
},
|
||||
onDragStart(e) {
|
||||
e.target.classList.add("ui-sortable-active");
|
||||
},
|
||||
onDragEnd(e) {
|
||||
e.target.classList.remove("ui-sortable-active");
|
||||
},
|
||||
onNetworkSort(e) {
|
||||
if (!e.moved) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit("sort", {
|
||||
type: "networks",
|
||||
order: this.$store.state.networks.map((n) => n.uuid),
|
||||
});
|
||||
},
|
||||
onChannelSort(e) {
|
||||
if (!e.moved) {
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = this.$store.getters.findChannel(e.moved.element.id);
|
||||
|
||||
if (!channel) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit("sort", {
|
||||
type: "channels",
|
||||
target: channel.network.uuid,
|
||||
order: channel.network.channels.map((c) => c.id),
|
||||
});
|
||||
},
|
||||
toggleSearch(event) {
|
||||
if (isIgnoredKeybind(event)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.$refs.searchInput === document.activeElement) {
|
||||
this.deactivateSearch();
|
||||
return false;
|
||||
}
|
||||
|
||||
this.activateSearch();
|
||||
return false;
|
||||
},
|
||||
activateSearch() {
|
||||
if (this.$refs.searchInput === document.activeElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.sidebarWasClosed = this.$store.state.sidebarOpen ? false : true;
|
||||
this.$store.commit("sidebarOpen", true);
|
||||
this.$nextTick(() => {
|
||||
this.$refs.searchInput.focus();
|
||||
});
|
||||
},
|
||||
deactivateSearch() {
|
||||
this.activeSearchItem = null;
|
||||
this.searchText = "";
|
||||
this.$refs.searchInput.blur();
|
||||
|
||||
if (this.sidebarWasClosed) {
|
||||
this.$store.commit("sidebarOpen", false);
|
||||
}
|
||||
},
|
||||
setSearchText(e) {
|
||||
this.searchText = e.target.value;
|
||||
},
|
||||
setActiveSearchItem(channel) {
|
||||
if (!this.results.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!channel) {
|
||||
channel = this.results[0].channel;
|
||||
}
|
||||
|
||||
this.activeSearchItem = channel;
|
||||
},
|
||||
selectResult() {
|
||||
if (!this.searchText || !this.results.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$root.switchToChannel(this.activeSearchItem);
|
||||
this.deactivateSearch();
|
||||
this.scrollToActive();
|
||||
},
|
||||
navigateResults(event, direction) {
|
||||
// Prevent propagation to stop global keybind handler from capturing pagedown/pageup
|
||||
// and redirecting it to the message list container for scrolling
|
||||
event.stopImmediatePropagation();
|
||||
event.preventDefault();
|
||||
|
||||
if (!this.searchText) {
|
||||
return;
|
||||
}
|
||||
|
||||
const channels = this.results.map((r) => r.channel);
|
||||
|
||||
// Bail out if there's no channels to select
|
||||
if (!channels.length) {
|
||||
this.activeSearchItem = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let currentIndex = channels.indexOf(this.activeSearchItem);
|
||||
|
||||
// If there's no active channel select the first or last one depending on direction
|
||||
if (!this.activeSearchItem || currentIndex === -1) {
|
||||
this.activeSearchItem = direction ? channels[0] : channels[channels.length - 1];
|
||||
this.scrollToActive();
|
||||
return;
|
||||
}
|
||||
|
||||
currentIndex += direction;
|
||||
|
||||
// Wrap around the list if necessary. Normaly each loop iterates once at most,
|
||||
// but might iterate more often if pgup or pgdown are used in a very short list
|
||||
while (currentIndex < 0) {
|
||||
currentIndex += channels.length;
|
||||
}
|
||||
|
||||
while (currentIndex > channels.length - 1) {
|
||||
currentIndex -= channels.length;
|
||||
}
|
||||
|
||||
this.activeSearchItem = channels[currentIndex];
|
||||
this.scrollToActive();
|
||||
},
|
||||
scrollToActive() {
|
||||
// Scroll the list if needed after the active class is applied
|
||||
this.$nextTick(() => {
|
||||
const el = this.$refs.networklist.querySelector(".channel-list-item.active");
|
||||
|
||||
if (el) {
|
||||
el.scrollIntoView({block: "nearest", inline: "nearest"});
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
84
client/components/NetworkLobby.vue
Normal file
@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<ChannelWrapper v-bind="$props" :channel="channel">
|
||||
<button
|
||||
v-if="network.channels.length > 1"
|
||||
:aria-controls="'network-' + network.uuid"
|
||||
:aria-label="getExpandLabel(network)"
|
||||
:aria-expanded="!network.isCollapsed"
|
||||
class="collapse-network"
|
||||
@click.stop="onCollapseClick"
|
||||
>
|
||||
<span class="collapse-network-icon" />
|
||||
</button>
|
||||
<span v-else class="collapse-network" />
|
||||
<div class="lobby-wrap">
|
||||
<span :title="channel.name" class="name">{{ channel.name }}</span>
|
||||
<span
|
||||
v-if="network.status.connected && !network.status.secure"
|
||||
class="not-secure-tooltip tooltipped tooltipped-w"
|
||||
aria-label="Insecure connection"
|
||||
>
|
||||
<span class="not-secure-icon" />
|
||||
</span>
|
||||
<span
|
||||
v-if="!network.status.connected"
|
||||
class="not-connected-tooltip tooltipped tooltipped-w"
|
||||
aria-label="Disconnected"
|
||||
>
|
||||
<span class="not-connected-icon" />
|
||||
</span>
|
||||
<span v-if="channel.unread" :class="{highlight: channel.highlight}" class="badge">{{
|
||||
unreadCount
|
||||
}}</span>
|
||||
</div>
|
||||
<span
|
||||
:aria-label="joinChannelLabel"
|
||||
class="add-channel-tooltip tooltipped tooltipped-w tooltipped-no-touch"
|
||||
>
|
||||
<button
|
||||
:class="['add-channel', {opened: isJoinChannelShown}]"
|
||||
:aria-controls="'join-channel-' + channel.id"
|
||||
:aria-label="joinChannelLabel"
|
||||
@click.stop="$emit('toggle-join-channel')"
|
||||
/>
|
||||
</span>
|
||||
</ChannelWrapper>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import collapseNetwork from "../js/helpers/collapseNetwork";
|
||||
import roundBadgeNumber from "../js/helpers/roundBadgeNumber";
|
||||
import ChannelWrapper from "./ChannelWrapper.vue";
|
||||
|
||||
export default {
|
||||
name: "Channel",
|
||||
components: {
|
||||
ChannelWrapper,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
isJoinChannelShown: Boolean,
|
||||
active: Boolean,
|
||||
isFiltering: Boolean,
|
||||
},
|
||||
computed: {
|
||||
channel() {
|
||||
return this.network.channels[0];
|
||||
},
|
||||
joinChannelLabel() {
|
||||
return this.isJoinChannelShown ? "Cancel" : "Join a channel…";
|
||||
},
|
||||
unreadCount() {
|
||||
return roundBadgeNumber(this.channel.unread);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onCollapseClick() {
|
||||
collapseNetwork(this.network, !this.network.isCollapsed);
|
||||
},
|
||||
getExpandLabel(network) {
|
||||
return network.isCollapsed ? "Expand" : "Collapse";
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
23
client/components/ParsedMessage.vue
Normal file
@ -0,0 +1,23 @@
|
||||
<script>
|
||||
import parse from "../js/helpers/parse";
|
||||
|
||||
export default {
|
||||
name: "ParsedMessage",
|
||||
functional: true,
|
||||
props: {
|
||||
text: String,
|
||||
message: Object,
|
||||
network: Object,
|
||||
},
|
||||
render(createElement, context) {
|
||||
return parse(
|
||||
createElement,
|
||||
typeof context.props.text !== "undefined"
|
||||
? context.props.text
|
||||
: context.props.message.text,
|
||||
context.props.message,
|
||||
context.props.network
|
||||
);
|
||||
},
|
||||
};
|
||||
</script>
|
33
client/components/RevealPassword.vue
Normal file
@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div>
|
||||
<slot :isVisible="isVisible" />
|
||||
<span
|
||||
ref="revealButton"
|
||||
type="button"
|
||||
:class="[
|
||||
'reveal-password tooltipped tooltipped-n tooltipped-no-delay',
|
||||
{'reveal-password-visible': isVisible},
|
||||
]"
|
||||
:aria-label="isVisible ? 'Hide password' : 'Show password'"
|
||||
@click="onClick"
|
||||
>
|
||||
<span :aria-label="isVisible ? 'Hide password' : 'Show password'" />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "RevealPassword",
|
||||
data() {
|
||||
return {
|
||||
isVisible: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
onClick() {
|
||||
this.isVisible = !this.isVisible;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
35
client/components/RoutedChat.vue
Normal file
@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<Chat v-if="activeChannel" :network="activeChannel.network" :channel="activeChannel.channel" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// Temporary component for routing channels and lobbies
|
||||
import Chat from "./Chat.vue";
|
||||
|
||||
export default {
|
||||
name: "RoutedChat",
|
||||
components: {
|
||||
Chat,
|
||||
},
|
||||
computed: {
|
||||
activeChannel() {
|
||||
const chanId = parseInt(this.$route.params.id, 10);
|
||||
const channel = this.$store.getters.findChannel(chanId);
|
||||
return channel;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
activeChannel() {
|
||||
this.setActiveChannel();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setActiveChannel();
|
||||
},
|
||||
methods: {
|
||||
setActiveChannel() {
|
||||
this.$store.commit("activeChannel", this.activeChannel);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
74
client/components/Session.vue
Normal file
@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div class="session-item">
|
||||
<div class="session-item-info">
|
||||
<strong>{{ session.agent }}</strong>
|
||||
|
||||
<a :href="'https://ipinfo.io/' + session.ip" target="_blank" rel="noopener">{{
|
||||
session.ip
|
||||
}}</a>
|
||||
|
||||
<p v-if="session.active > 1" class="session-usage">
|
||||
Active in {{ session.active }} browsers
|
||||
</p>
|
||||
<p v-else-if="!session.current && !session.active" class="session-usage">
|
||||
Last used on <time>{{ lastUse }}</time>
|
||||
</p>
|
||||
</div>
|
||||
<div class="session-item-btn">
|
||||
<button class="btn" @click.prevent="signOut">
|
||||
<template v-if="session.current">Sign out</template>
|
||||
<template v-else>Revoke</template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.session-list .session-item {
|
||||
display: flex;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.session-list .session-item-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.session-list .session-item-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.session-list .session-usage {
|
||||
font-style: italic;
|
||||
color: var(--body-color-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import localetime from "../js/helpers/localetime";
|
||||
import Auth from "../js/auth";
|
||||
import socket from "../js/socket";
|
||||
|
||||
export default {
|
||||
name: "Session",
|
||||
props: {
|
||||
session: Object,
|
||||
},
|
||||
computed: {
|
||||
lastUse() {
|
||||
return localetime(this.session.lastUse);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
signOut() {
|
||||
if (!this.session.current) {
|
||||
socket.emit("sign-out", this.session.token);
|
||||
} else {
|
||||
socket.emit("sign-out");
|
||||
Auth.signout();
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
200
client/components/Sidebar.vue
Normal file
@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<aside id="sidebar" ref="sidebar">
|
||||
<div class="scrollable-area">
|
||||
<div class="logo-container">
|
||||
<img
|
||||
:src="`img/logo-${isPublic() ? 'horizontal-' : ''}transparent-bg.svg`"
|
||||
class="logo"
|
||||
alt="TripSit Web"
|
||||
/>
|
||||
<img
|
||||
:src="`img/logo-${isPublic() ? 'horizontal-' : ''}transparent-bg-inverted.svg`"
|
||||
class="logo-inverted"
|
||||
alt="TripSit Web"
|
||||
/>
|
||||
<span
|
||||
v-if="isDevelopment"
|
||||
title="The Lounge has been built in development mode"
|
||||
:style="{
|
||||
backgroundColor: '#ff9e18',
|
||||
color: '#000',
|
||||
padding: '2px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
}"
|
||||
>DEVELOPER</span
|
||||
>
|
||||
</div>
|
||||
<NetworkList />
|
||||
</div>
|
||||
<footer id="footer">
|
||||
<!--span
|
||||
class="tooltipped tooltipped-n tooltipped-no-touch"
|
||||
aria-label="Connect to network"
|
||||
><router-link
|
||||
to="/connect"
|
||||
tag="button"
|
||||
active-class="active"
|
||||
:class="['icon', 'connect']"
|
||||
aria-label="Connect to TripSit"
|
||||
role="tab"
|
||||
aria-controls="connect"
|
||||
:aria-selected="$route.name === 'Connect'"
|
||||
/></span-->
|
||||
<span class="tooltipped tooltipped-n tooltipped-no-touch" aria-label="Settings"
|
||||
><router-link
|
||||
to="/settings"
|
||||
tag="button"
|
||||
active-class="active"
|
||||
:class="['icon', 'settings']"
|
||||
aria-label="Settings"
|
||||
role="tab"
|
||||
aria-controls="settings"
|
||||
:aria-selected="$route.name === 'Settings'"
|
||||
/></span>
|
||||
<span
|
||||
class="tooltipped tooltipped-n tooltipped-no-touch"
|
||||
:aria-label="
|
||||
$store.state.serverConfiguration.isUpdateAvailable
|
||||
? 'Help\n(update available)'
|
||||
: 'Help'
|
||||
"
|
||||
><router-link
|
||||
to="/help"
|
||||
tag="button"
|
||||
active-class="active"
|
||||
:class="[
|
||||
'icon',
|
||||
'help',
|
||||
{notified: $store.state.serverConfiguration.isUpdateAvailable},
|
||||
]"
|
||||
aria-label="Help"
|
||||
role="tab"
|
||||
aria-controls="help"
|
||||
:aria-selected="$route.name === 'Help'"
|
||||
/></span>
|
||||
</footer>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import NetworkList from "./NetworkList.vue";
|
||||
|
||||
export default {
|
||||
name: "Sidebar",
|
||||
components: {
|
||||
NetworkList,
|
||||
},
|
||||
props: {
|
||||
overlay: HTMLElement,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isDevelopment: process.env.NODE_ENV !== "production",
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.touchStartPos = null;
|
||||
this.touchCurPos = null;
|
||||
this.touchStartTime = 0;
|
||||
this.menuWidth = 0;
|
||||
this.menuIsMoving = false;
|
||||
this.menuIsAbsolute = false;
|
||||
|
||||
this.onTouchStart = (e) => {
|
||||
this.touchStartPos = this.touchCurPos = e.touches.item(0);
|
||||
|
||||
if (e.touches.length !== 1) {
|
||||
this.onTouchEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
const styles = window.getComputedStyle(this.$refs.sidebar);
|
||||
|
||||
this.menuWidth = parseFloat(styles.width);
|
||||
this.menuIsAbsolute = styles.position === "absolute";
|
||||
|
||||
if (!this.$store.state.sidebarOpen || this.touchStartPos.screenX > this.menuWidth) {
|
||||
this.touchStartTime = Date.now();
|
||||
|
||||
document.body.addEventListener("touchmove", this.onTouchMove, {passive: true});
|
||||
document.body.addEventListener("touchend", this.onTouchEnd, {passive: true});
|
||||
}
|
||||
};
|
||||
|
||||
this.onTouchMove = (e) => {
|
||||
const touch = (this.touchCurPos = e.touches.item(0));
|
||||
let distX = touch.screenX - this.touchStartPos.screenX;
|
||||
const distY = touch.screenY - this.touchStartPos.screenY;
|
||||
|
||||
if (!this.menuIsMoving) {
|
||||
// tan(45°) is 1. Gestures in 0°-45° (< 1) are considered horizontal, so
|
||||
// menu must be open; gestures in 45°-90° (>1) are considered vertical, so
|
||||
// chat windows must be scrolled.
|
||||
if (Math.abs(distY / distX) >= 1) {
|
||||
this.onTouchEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
const devicePixelRatio = window.devicePixelRatio || 2;
|
||||
|
||||
if (Math.abs(distX) > devicePixelRatio) {
|
||||
this.$store.commit("sidebarDragging", true);
|
||||
this.menuIsMoving = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Do not animate the menu on desktop view
|
||||
if (!this.menuIsAbsolute) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.$store.state.sidebarOpen) {
|
||||
distX += this.menuWidth;
|
||||
}
|
||||
|
||||
if (distX > this.menuWidth) {
|
||||
distX = this.menuWidth;
|
||||
} else if (distX < 0) {
|
||||
distX = 0;
|
||||
}
|
||||
|
||||
this.$refs.sidebar.style.transform = "translate3d(" + distX + "px, 0, 0)";
|
||||
this.overlay.style.opacity = distX / this.menuWidth;
|
||||
};
|
||||
|
||||
this.onTouchEnd = () => {
|
||||
const diff = this.touchCurPos.screenX - this.touchStartPos.screenX;
|
||||
const absDiff = Math.abs(diff);
|
||||
|
||||
if (
|
||||
absDiff > this.menuWidth / 2 ||
|
||||
(Date.now() - this.touchStartTime < 180 && absDiff > 50)
|
||||
) {
|
||||
this.toggle(diff > 0);
|
||||
}
|
||||
|
||||
document.body.removeEventListener("touchmove", this.onTouchMove);
|
||||
document.body.removeEventListener("touchend", this.onTouchEnd);
|
||||
this.$store.commit("sidebarDragging", false);
|
||||
|
||||
this.$refs.sidebar.style.transform = null;
|
||||
this.overlay.style.opacity = null;
|
||||
|
||||
this.touchStartPos = null;
|
||||
this.touchCurPos = null;
|
||||
this.touchStartTime = 0;
|
||||
this.menuIsMoving = false;
|
||||
};
|
||||
|
||||
this.toggle = (state) => {
|
||||
this.$store.commit("sidebarOpen", state);
|
||||
};
|
||||
|
||||
document.body.addEventListener("touchstart", this.onTouchStart, {passive: true});
|
||||
},
|
||||
methods: {
|
||||
isPublic: () => document.body.classList.contains("public"),
|
||||
},
|
||||
};
|
||||
</script>
|
9
client/components/SidebarToggle.vue
Normal file
@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<button class="lt" aria-label="Toggle channel list" @click="$store.commit('toggleSidebar')" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "SidebarToggle",
|
||||
};
|
||||
</script>
|
35
client/components/Special/ListBans.vue
Normal file
@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<table class="ban-list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="hostmask">Banned</th>
|
||||
<th class="banned_by">Banned By</th>
|
||||
<th class="banned_at">Banned At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="ban in channel.data" :key="ban.hostmask">
|
||||
<td class="hostmask">{{ ban.hostmask }}</td>
|
||||
<td class="banned_by">{{ ban.banned_by }}</td>
|
||||
<td class="banned_at">{{ localetime(ban.banned_at) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import localetime from "../../js/helpers/localetime";
|
||||
|
||||
export default {
|
||||
name: "ListBans",
|
||||
props: {
|
||||
network: Object,
|
||||
channel: Object,
|
||||
},
|
||||
methods: {
|
||||
localetime(date) {
|
||||
return localetime(date);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
34
client/components/Special/ListChannels.vue
Normal file
@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<span v-if="channel.data.text">{{ channel.data.text }}</span>
|
||||
<table v-else class="channel-list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="channel">Channel</th>
|
||||
<th class="users">Users</th>
|
||||
<th class="topic">Topic</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="chan in channel.data" :key="chan.channel">
|
||||
<td class="channel"><ParsedMessage :network="network" :text="chan.channel" /></td>
|
||||
<td class="users">{{ chan.num_users }}</td>
|
||||
<td class="topic"><ParsedMessage :network="network" :text="chan.topic" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
|
||||
export default {
|
||||
name: "ListChannels",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
channel: Object,
|
||||
},
|
||||
};
|
||||
</script>
|
33
client/components/Special/ListIgnored.vue
Normal file
@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<table class="ignore-list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="hostmask">Hostmask</th>
|
||||
<th class="when">Ignored At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in channel.data" :key="user.hostmask">
|
||||
<td class="hostmask">{{ user.hostmask }}</td>
|
||||
<td class="when">{{ localetime(user.when) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import localetime from "../../js/helpers/localetime";
|
||||
|
||||
export default {
|
||||
name: "ListIgnored",
|
||||
props: {
|
||||
network: Object,
|
||||
channel: Object,
|
||||
},
|
||||
methods: {
|
||||
localetime(date) {
|
||||
return localetime(date);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
35
client/components/Special/ListInvites.vue
Normal file
@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<table class="invite-list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="hostmask">Invited</th>
|
||||
<th class="invitened_by">Invited By</th>
|
||||
<th class="invitened_at">Invited At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="invite in channel.data" :key="invite.hostmask">
|
||||
<td class="hostmask">{{ invite.hostmask }}</td>
|
||||
<td class="invitened_by">{{ invite.invited_by }}</td>
|
||||
<td class="invitened_at">{{ localetime(invite.invited_at) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import localetime from "../../js/helpers/localetime";
|
||||
|
||||
export default {
|
||||
name: "ListInvites",
|
||||
props: {
|
||||
network: Object,
|
||||
channel: Object,
|
||||
},
|
||||
methods: {
|
||||
localetime(date) {
|
||||
return localetime(date);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
49
client/components/Username.vue
Normal file
@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<span
|
||||
:class="['user', nickColor, {active: active}]"
|
||||
:data-name="user.nick"
|
||||
role="button"
|
||||
v-on="onHover ? {mouseenter: hover} : {}"
|
||||
@click.prevent="openContextMenu"
|
||||
@contextmenu.prevent="openContextMenu"
|
||||
><slot>{{ mode }}{{ user.nick }}</slot></span
|
||||
>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import eventbus from "../js/eventbus";
|
||||
import colorClass from "../js/helpers/colorClass";
|
||||
|
||||
export default {
|
||||
name: "Username",
|
||||
props: {
|
||||
user: Object,
|
||||
active: Boolean,
|
||||
onHover: Function,
|
||||
},
|
||||
computed: {
|
||||
mode() {
|
||||
// Message objects have a singular mode, but user objects have modes array
|
||||
if (this.user.modes) {
|
||||
return this.user.modes[0];
|
||||
}
|
||||
|
||||
return this.user.mode;
|
||||
},
|
||||
nickColor() {
|
||||
return colorClass(this.user.nick);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
hover() {
|
||||
return this.onHover(this.user);
|
||||
},
|
||||
openContextMenu(event) {
|
||||
eventbus.emit("contextmenu:user", {
|
||||
event: event,
|
||||
user: this.user,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
56
client/components/VersionChecker.vue
Normal file
@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<!--div id="version-checker" :class="[$store.state.versionStatus]">
|
||||
<p v-if="$store.state.versionStatus === 'loading'">Checking for updates…</p>
|
||||
<p v-if="$store.state.versionStatus === 'new-version'">
|
||||
The Lounge <b>{{ $store.state.versionData.latest.version }}</b>
|
||||
<template v-if="$store.state.versionData.latest.prerelease"> (pre-release) </template>
|
||||
is now available.
|
||||
<br />
|
||||
|
||||
<a :href="$store.state.versionData.latest.url" target="_blank" rel="noopener">
|
||||
Read more on GitHub
|
||||
</a>
|
||||
</p>
|
||||
<p v-if="$store.state.versionStatus === 'new-packages'">
|
||||
The TripSit Lounge is up to date, but there are out of date packages Run
|
||||
<code>thelounge upgrade</code> on the server to upgrade packages.
|
||||
</p>
|
||||
<template v-if="$store.state.versionStatus === 'up-to-date'">
|
||||
<p>The Lounge, the software TripSit relies on, is up to date!</p>
|
||||
|
||||
<button
|
||||
v-if="$store.state.versionDataExpired"
|
||||
id="check-now"
|
||||
class="btn btn-small"
|
||||
@click="checkNow"
|
||||
>
|
||||
Check now
|
||||
</button>
|
||||
</template>
|
||||
<template v-if="$store.state.versionStatus === 'error'">
|
||||
<p>Information about latest release could not be retrieved.</p>
|
||||
|
||||
<button id="check-now" class="btn btn-small" @click="checkNow">Try again</button>
|
||||
</template>
|
||||
</div-->
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import socket from "../js/socket";
|
||||
|
||||
export default {
|
||||
name: "VersionChecker",
|
||||
mounted() {
|
||||
if (!this.$store.state.versionData) {
|
||||
this.checkNow();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
checkNow() {
|
||||
this.$store.commit("versionData", null);
|
||||
this.$store.commit("versionStatus", "loading");
|
||||
socket.emit("changelog");
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
85
client/components/Windows/Changelog.vue
Normal file
@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div id="changelog" class="window" aria-label="Changelog">
|
||||
<div class="header">
|
||||
<SidebarToggle />
|
||||
</div>
|
||||
<div class="container">
|
||||
<router-link id="back-to-help" to="/help">« Help</router-link>
|
||||
|
||||
<template
|
||||
v-if="
|
||||
$store.state.versionData &&
|
||||
$store.state.versionData.current &&
|
||||
$store.state.versionData.current.version
|
||||
"
|
||||
>
|
||||
<h1 class="title">
|
||||
Release notes for {{ $store.state.versionData.current.version }}
|
||||
</h1>
|
||||
|
||||
<template v-if="$store.state.versionData.current.changelog">
|
||||
<h3>Introduction</h3>
|
||||
<div
|
||||
ref="changelog"
|
||||
class="changelog-text"
|
||||
v-html="$store.state.versionData.current.changelog"
|
||||
></div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p>Unable to retrieve changelog for current release from GitHub.</p>
|
||||
<p>
|
||||
<a
|
||||
:href="`https://github.com/thelounge/thelounge/releases/tag/v${$store.state.serverConfiguration.version}`"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>View release notes for this version on GitHub</a
|
||||
>
|
||||
</p>
|
||||
</template>
|
||||
</template>
|
||||
<p v-else>Loading changelog…</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import socket from "../../js/socket";
|
||||
import SidebarToggle from "../SidebarToggle.vue";
|
||||
|
||||
export default {
|
||||
name: "Changelog",
|
||||
components: {
|
||||
SidebarToggle,
|
||||
},
|
||||
mounted() {
|
||||
if (!this.$store.state.versionData) {
|
||||
socket.emit("changelog");
|
||||
}
|
||||
|
||||
this.patchChangelog();
|
||||
},
|
||||
updated() {
|
||||
this.patchChangelog();
|
||||
},
|
||||
methods: {
|
||||
patchChangelog() {
|
||||
if (!this.$refs.changelog) {
|
||||
return;
|
||||
}
|
||||
|
||||
const links = this.$refs.changelog.querySelectorAll("a");
|
||||
|
||||
for (const link of links) {
|
||||
// Make sure all links will open a new tab instead of exiting the application
|
||||
link.setAttribute("target", "_blank");
|
||||
link.setAttribute("rel", "noopener");
|
||||
|
||||
if (link.querySelector("img")) {
|
||||
// Add required metadata to image links, to support built-in image viewer
|
||||
link.classList.add("toggle-thumbnail");
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
103
client/components/Windows/Connect.vue
Normal file
@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<NetworkForm :handle-submit="handleSubmit" :defaults="defaults" :disabled="disabled" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import socket from "../../js/socket";
|
||||
import NetworkForm from "../NetworkForm.vue";
|
||||
|
||||
export default {
|
||||
name: "Connect",
|
||||
components: {
|
||||
NetworkForm,
|
||||
},
|
||||
props: {
|
||||
queryParams: Object,
|
||||
},
|
||||
data() {
|
||||
// Merge settings from url params into default settings
|
||||
const defaults = Object.assign(
|
||||
{},
|
||||
this.$store.state.serverConfiguration.defaults,
|
||||
this.parseOverrideParams(this.queryParams)
|
||||
);
|
||||
return {
|
||||
disabled: false,
|
||||
defaults,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
handleSubmit(data) {
|
||||
this.disabled = true;
|
||||
socket.emit("network:new", data);
|
||||
},
|
||||
parseOverrideParams(params) {
|
||||
const parsedParams = {};
|
||||
|
||||
for (let key of Object.keys(params)) {
|
||||
let value = params[key];
|
||||
|
||||
// Param can contain multiple values in an array if its supplied more than once
|
||||
if (Array.isArray(value)) {
|
||||
value = value[0];
|
||||
}
|
||||
|
||||
// Support `channels` as a compatibility alias with other clients
|
||||
if (key === "channels") {
|
||||
key = "join";
|
||||
}
|
||||
|
||||
if (
|
||||
!Object.prototype.hasOwnProperty.call(
|
||||
this.$store.state.serverConfiguration.defaults,
|
||||
key
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// When the network is locked, URL overrides should not affect disabled fields
|
||||
if (
|
||||
this.$store.state.serverConfiguration.lockNetwork &&
|
||||
["name", "host", "port", "tls", "rejectUnauthorized"].includes(key)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === "join") {
|
||||
value = value
|
||||
.split(",")
|
||||
.map((chan) => {
|
||||
if (!chan.match(/^[#&!+]/)) {
|
||||
return `#${chan}`;
|
||||
}
|
||||
|
||||
return chan;
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
// Override server provided defaults with parameters passed in the URL if they match the data type
|
||||
switch (typeof this.$store.state.serverConfiguration.defaults[key]) {
|
||||
case "boolean":
|
||||
if (value === "0" || value === "false") {
|
||||
parsedParams[key] = false;
|
||||
} else {
|
||||
parsedParams[key] = !!value;
|
||||
}
|
||||
|
||||
break;
|
||||
case "number":
|
||||
parsedParams[key] = Number(value);
|
||||
break;
|
||||
case "string":
|
||||
parsedParams[key] = String(value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return parsedParams;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
759
client/components/Windows/Help.vue
Normal file
@ -0,0 +1,759 @@
|
||||
<template>
|
||||
<div id="help" class="window" role="tabpanel" aria-label="Help">
|
||||
<div class="header">
|
||||
<SidebarToggle />
|
||||
</div>
|
||||
<div class="container">
|
||||
<h1 class="title">Help</h1>
|
||||
|
||||
<!--h2 class="help-version-title">
|
||||
<span>About The Lounge</span>
|
||||
<small>
|
||||
v{{ $store.state.serverConfiguration.version }} (<router-link
|
||||
id="view-changelog"
|
||||
to="/changelog"
|
||||
>release notes</router-link
|
||||
>)
|
||||
</small>
|
||||
</h2-->
|
||||
|
||||
<h2 class="help-version-title">
|
||||
<span>The TripSit Web Client</span>
|
||||
<small>
|
||||
<p>This Client is powered by The Lounge and was modified to fit the TripSit harm reduction network</p>
|
||||
<p>Please share your feedback in #contrib</p>
|
||||
</small>
|
||||
</h2>
|
||||
|
||||
<!--div class="about">
|
||||
<VersionChecker />
|
||||
|
||||
<template v-if="$store.state.serverConfiguration.gitCommit">
|
||||
<p>
|
||||
The Lounge is running from source (<a
|
||||
:href="`https://github.com/thelounge/thelounge/tree/${$store.state.serverConfiguration.gitCommit}`"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>commit <code>{{ $store.state.serverConfiguration.gitCommit }}</code></a
|
||||
>).
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
Compare
|
||||
<a
|
||||
:href="`https://github.com/thelounge/thelounge/compare/${$store.state.serverConfiguration.gitCommit}...master`"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>between
|
||||
<code>{{ $store.state.serverConfiguration.gitCommit }}</code> and
|
||||
<code>master</code></a
|
||||
>
|
||||
to see what you are missing
|
||||
</li>
|
||||
<li>
|
||||
Compare
|
||||
<a
|
||||
:href="`https://github.com/thelounge/thelounge/compare/${$store.state.serverConfiguration.version}...${$store.state.serverConfiguration.gitCommit}`"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>between
|
||||
<code>{{ $store.state.serverConfiguration.version }}</code> and
|
||||
<code>{{ $store.state.serverConfiguration.gitCommit }}</code></a
|
||||
>
|
||||
to see your local changes
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<p>
|
||||
<a
|
||||
href="https://thelounge.chat/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="website-link"
|
||||
>Website</a
|
||||
>
|
||||
</p>
|
||||
<p>
|
||||
<a
|
||||
href="https://thelounge.chat/docs/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="documentation-link"
|
||||
>Documentation</a
|
||||
>
|
||||
</p>
|
||||
<p>
|
||||
<a
|
||||
href="https://github.com/thelounge/thelounge/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="report-issue-link"
|
||||
>Report an issue…</a
|
||||
>
|
||||
</p>
|
||||
</div-->
|
||||
|
||||
<h2>Keyboard Shortcuts</h2>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<span v-if="!isApple"><kbd>Alt</kbd> <kbd>Shift</kbd> <kbd>↓</kbd></span>
|
||||
<span v-else><kbd>⌥</kbd> <kbd>⇧</kbd> <kbd>↓</kbd></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Switch to the next lobby in the channel list.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<span v-if="!isApple"><kbd>Alt</kbd> <kbd>Shift</kbd> <kbd>↑</kbd></span>
|
||||
<span v-else><kbd>⌥</kbd> <kbd>⇧</kbd> <kbd>↑</kbd></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Switch to the previous lobby in the channel list.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<span v-if="!isApple"><kbd>Alt</kbd> <kbd>Shift</kbd> <kbd>←</kbd></span>
|
||||
<span v-else><kbd>⌥</kbd> <kbd>⇧</kbd> <kbd>←</kbd></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Collapse current network.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<span v-if="!isApple"><kbd>Alt</kbd> <kbd>Shift</kbd> <kbd>→</kbd></span>
|
||||
<span v-else><kbd>⌥</kbd> <kbd>⇧</kbd> <kbd>→</kbd></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Expand current network.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<span v-if="!isApple"><kbd>Alt</kbd> <kbd>↓</kbd></span>
|
||||
<span v-else><kbd>⌥</kbd> <kbd>↓</kbd></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Switch to the next window in the channel list.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<span v-if="!isApple"><kbd>Alt</kbd> <kbd>↑</kbd></span>
|
||||
<span v-else><kbd>⌥</kbd> <kbd>↑</kbd></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Switch to the previous window in the channel list.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<span v-if="!isApple"><kbd>Alt</kbd> <kbd>A</kbd></span>
|
||||
<span v-else><kbd>⌥</kbd> <kbd>A</kbd></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Switch to the first window with unread messages.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<span v-if="!isApple"><kbd>Alt</kbd> <kbd>S</kbd></span>
|
||||
<span v-else><kbd>⌥</kbd> <kbd>S</kbd></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Toggle sidebar.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<span v-if="!isApple"><kbd>Alt</kbd> <kbd>U</kbd></span>
|
||||
<span v-else><kbd>⌥</kbd> <kbd>U</kbd></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Toggle channel user list.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<span v-if="!isApple"><kbd>Alt</kbd> <kbd>J</kbd></span>
|
||||
<span v-else><kbd>⌥</kbd> <kbd>J</kbd></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Toggle jump to channel switcher.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<span><kbd>Esc</kbd></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Close current contextual window (context menu, image viewer, topic edit,
|
||||
etc) and remove focus from input.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Formatting Shortcuts</h2>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<span v-if="!isApple"><kbd>Ctrl</kbd> <kbd>K</kbd></span>
|
||||
<span v-else><kbd>⌘</kbd> <kbd>K</kbd></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Mark any text typed after this shortcut to be colored. After hitting this
|
||||
shortcut, enter an integer in the range
|
||||
<code>0—15</code> to select the desired color, or use the autocompletion
|
||||
menu to choose a color name (see below).
|
||||
</p>
|
||||
<p>
|
||||
Background color can be specified by putting a comma and another integer in
|
||||
the range <code>0—15</code> after the foreground color number
|
||||
(autocompletion works too).
|
||||
</p>
|
||||
<p>
|
||||
A color reference can be found
|
||||
<a
|
||||
href="https://modern.ircdocs.horse/formatting.html#colors"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>here</a
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<span v-if="!isApple"><kbd>Ctrl</kbd> <kbd>B</kbd></span>
|
||||
<span v-else><kbd>⌘</kbd> <kbd>B</kbd></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Mark all text typed after this shortcut as
|
||||
<span class="irc-bold">bold</span>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<span v-if="!isApple"><kbd>Ctrl</kbd> <kbd>U</kbd></span>
|
||||
<span v-else><kbd>⌘</kbd> <kbd>U</kbd></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Mark all text typed after this shortcut as
|
||||
<span class="irc-underline">underlined</span>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<span v-if="!isApple"><kbd>Ctrl</kbd> <kbd>I</kbd></span>
|
||||
<span v-else><kbd>⌘</kbd> <kbd>I</kbd></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Mark all text typed after this shortcut as
|
||||
<span class="irc-italic">italics</span>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<span v-if="!isApple"><kbd>Ctrl</kbd> <kbd>S</kbd></span>
|
||||
<span v-else><kbd>⌘</kbd> <kbd>S</kbd></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Mark all text typed after this shortcut as
|
||||
<span class="irc-strikethrough">struck through</span>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<span v-if="!isApple"><kbd>Ctrl</kbd> <kbd>M</kbd></span>
|
||||
<span v-else><kbd>⌘</kbd> <kbd>M</kbd></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Mark all text typed after this shortcut as
|
||||
<span class="irc-monospace">monospaced</span>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<span v-if="!isApple"><kbd>Ctrl</kbd> <kbd>O</kbd></span>
|
||||
<span v-else><kbd>⌘</kbd> <kbd>O</kbd></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Mark all text typed after this shortcut to be reset to its original
|
||||
formatting.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Autocompletion</h2>
|
||||
|
||||
<p>
|
||||
To auto-complete nicknames, channels, commands, and emoji, type one of the
|
||||
characters below to open a suggestion list. Use the <kbd>↑</kbd> and
|
||||
<kbd>↓</kbd> keys to highlight an item, and insert it by pressing <kbd>Tab</kbd> or
|
||||
<kbd>Enter</kbd> (or by clicking the desired item).
|
||||
</p>
|
||||
<p>Autocompletion can be disabled in settings.</p>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>@</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Nickname</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>#</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Channel</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Commands (see list of commands below)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>:</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Emoji (note: requires two search characters, to avoid conflicting with
|
||||
common emoticons like <code>:)</code>)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Commands</h2>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/away [message]</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Mark yourself as away with an optional message.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/back</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Remove your away status (set with <code>/away</code>).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/ban nick</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Ban (<code>+b</code>) a user from the current channel. This can be a
|
||||
nickname or a hostmask.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/banlist</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Load the banlist for the current channel.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/collapse</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Collapse all previews in the current channel (opposite of
|
||||
<code>/expand</code>)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/connect host [port]</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Connect to a new IRC network. If <code>port</code> starts with a
|
||||
<code>+</code> sign, the connection will be made secure using TLS.
|
||||
</p>
|
||||
<p>Alias: <code>/server</code></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/ctcp target cmd [args]</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Send a <abbr title="Client-to-client protocol">CTCP</abbr>
|
||||
request. Read more about this on
|
||||
<a
|
||||
href="https://en.wikipedia.org/wiki/Client-to-client_protocol"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>the dedicated Wikipedia article</a
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/deop nick [...nick]</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Remove op (<code>-o</code>) from one or several users in the current
|
||||
channel.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/devoice nick [...nick]</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Remove voice (<code>-v</code>) from one or several users in the current
|
||||
channel.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/disconnect [message]</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Disconnect from the current network with an optionally-provided message.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/expand</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Expand all previews in the current channel (opposite of
|
||||
<code>/collapse</code>)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/invite nick [channel]</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Invite a user to the specified channel. If
|
||||
<code>channel</code> is omitted, user will be invited to the current
|
||||
channel.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/ignore nick</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Block any messages from the specified user on the current network. This can
|
||||
be a nickname or a hostmask.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/ignorelist</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Load the list of ignored users for the current network.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/join channel</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Join a channel.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/kick nick</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Kick a user from the current channel.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/list</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Retrieve a list of available channels on this network.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/me message</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Send an action message to the current channel. The Lounge will display it
|
||||
inline, as if the message was posted in the third person.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/mode flags [args]</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Set the given flags to the current channel if the active window is a
|
||||
channel, another user if the active window is a private message window, or
|
||||
yourself if the current window is a server window.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/msg channel message</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Send a message to the specified channel.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/nick newnick</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Change your nickname on the current network.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/notice channel message</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Sends a notice message to the specified channel.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/op nick [...nick]</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Give op (<code>+o</code>) to one or several users in the current channel.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/part [channel]</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Close the specified channel or private message window, or the current
|
||||
channel if <code>channel</code> is omitted.
|
||||
</p>
|
||||
<p>Aliases: <code>/close</code>, <code>/leave</code></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/rejoin</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Leave and immediately rejoin the current channel. Useful to quickly get op
|
||||
from ChanServ in an empty channel, for example.
|
||||
</p>
|
||||
<p>Alias: <code>/cycle</code></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/query nick</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Send a private message to the specified user.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/quit [message]</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Disconnect from the current network with an optional message.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/raw message</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Send a raw message to the current IRC network.</p>
|
||||
<p>Aliases: <code>/quote</code>, <code>/send</code></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/slap nick</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Slap someone in the current channel with a trout!</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/topic [newtopic]</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Get the topic in the current channel. If <code>newtopic</code> is specified,
|
||||
sets the topic in the current channel.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/unban nick</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Unban (<code>-b</code>) a user from the current channel. This can be a
|
||||
nickname or a hostmask.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/unignore nick</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Unblock messages from the specified user on the current network. This can be
|
||||
a nickname or a hostmask.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/voice nick [...nick]</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Give voice (<code>+v</code>) to one or several users in the current channel.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/whois nick</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Retrieve information about the given user on the current network.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import SidebarToggle from "../SidebarToggle.vue";
|
||||
import VersionChecker from "../VersionChecker.vue";
|
||||
|
||||
export default {
|
||||
name: "Help",
|
||||
components: {
|
||||
SidebarToggle,
|
||||
VersionChecker,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isApple: navigator.platform.match(/(Mac|iPhone|iPod|iPad)/i) || false,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
50
client/components/Windows/NetworkEdit.vue
Normal file
@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<NetworkForm
|
||||
v-if="networkData"
|
||||
:handle-submit="handleSubmit"
|
||||
:defaults="networkData"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import socket from "../../js/socket";
|
||||
import NetworkForm from "../NetworkForm.vue";
|
||||
|
||||
export default {
|
||||
name: "NetworkEdit",
|
||||
components: {
|
||||
NetworkForm,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
disabled: false,
|
||||
networkData: null,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
"$route.params.uuid"() {
|
||||
this.setNetworkData();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setNetworkData();
|
||||
},
|
||||
methods: {
|
||||
setNetworkData() {
|
||||
socket.emit("network:get", this.$route.params.uuid);
|
||||
this.networkData = this.$store.getters.findNetwork(this.$route.params.uuid);
|
||||
},
|
||||
handleSubmit(data) {
|
||||
this.disabled = true;
|
||||
socket.emit("network:edit", data);
|
||||
|
||||
// TODO: move networks to vuex and update state when the network info comes in
|
||||
const network = this.$store.getters.findNetwork(data.uuid);
|
||||
network.name = network.channels[0].name = data.name;
|
||||
|
||||
this.$root.switchToChannel(network.channels[0]);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
652
client/components/Windows/Settings.vue
Normal file
@ -0,0 +1,652 @@
|
||||
<template>
|
||||
<div id="settings" class="window" role="tabpanel" aria-label="Settings">
|
||||
<div class="header">
|
||||
<SidebarToggle />
|
||||
</div>
|
||||
<form ref="settingsForm" class="container" @change="onChange" @submit.prevent>
|
||||
<h1 class="title">Settings</h1>
|
||||
|
||||
<div>
|
||||
<label class="opt">
|
||||
<input
|
||||
:checked="$store.state.settings.advanced"
|
||||
type="checkbox"
|
||||
name="advanced"
|
||||
/>
|
||||
Advanced settings
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="canRegisterProtocol || hasInstallPromptEvent">
|
||||
<h2>Native app</h2>
|
||||
<button
|
||||
v-if="hasInstallPromptEvent"
|
||||
type="button"
|
||||
class="btn"
|
||||
@click.prevent="nativeInstallPrompt"
|
||||
>
|
||||
Add The Lounge to Home screen
|
||||
</button>
|
||||
<button
|
||||
v-if="canRegisterProtocol"
|
||||
type="button"
|
||||
class="btn"
|
||||
@click.prevent="registerProtocol"
|
||||
>
|
||||
Open irc:// URLs with The Lounge
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!$store.state.serverConfiguration.public && $store.state.settings.advanced">
|
||||
<h2>Settings synchronisation</h2>
|
||||
<label class="opt">
|
||||
<input
|
||||
:checked="$store.state.settings.syncSettings"
|
||||
type="checkbox"
|
||||
name="syncSettings"
|
||||
/>
|
||||
Synchronize settings with other clients
|
||||
</label>
|
||||
<template v-if="!$store.state.settings.syncSettings">
|
||||
<div v-if="$store.state.serverHasSettings" class="settings-sync-panel">
|
||||
<p>
|
||||
<strong>Warning:</strong> Checking this box will override the settings
|
||||
of this client with those stored on the server.
|
||||
</p>
|
||||
<p>
|
||||
Use the button below to enable synchronization, and override any
|
||||
settings already synced to the server.
|
||||
</p>
|
||||
<button type="button" class="btn btn-small" @click="onForceSyncClick">
|
||||
Sync settings and enable
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="settings-sync-panel">
|
||||
<p>
|
||||
<strong>Warning:</strong> No settings have been synced before. Enabling
|
||||
this will sync all settings of this client as the base for other
|
||||
clients.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<h2>Messages</h2>
|
||||
<div>
|
||||
<label class="opt">
|
||||
<input :checked="$store.state.settings.motd" type="checkbox" name="motd" />
|
||||
Show <abbr title="Message Of The Day">MOTD</abbr>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="opt">
|
||||
<input
|
||||
:checked="$store.state.settings.showSeconds"
|
||||
type="checkbox"
|
||||
name="showSeconds"
|
||||
/>
|
||||
Show seconds in timestamp
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="opt">
|
||||
<input
|
||||
:checked="$store.state.settings.use12hClock"
|
||||
type="checkbox"
|
||||
name="use12hClock"
|
||||
/>
|
||||
Show 12-hour timestamps
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="!$store.state.serverConfiguration.public && $store.state.settings.advanced">
|
||||
<h2>Automatic away message</h2>
|
||||
|
||||
<label class="opt">
|
||||
<label for="awayMessage" class="sr-only">Automatic away message</label>
|
||||
<input
|
||||
id="awayMessage"
|
||||
:value="$store.state.settings.awayMessage"
|
||||
type="text"
|
||||
name="awayMessage"
|
||||
class="input"
|
||||
placeholder="Away message if The Lounge is not open"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<h2 id="label-status-messages">
|
||||
Status messages
|
||||
<span
|
||||
class="tooltipped tooltipped-n tooltipped-no-delay"
|
||||
aria-label="Joins, parts, quits, kicks, nick changes, and mode changes"
|
||||
>
|
||||
<button class="extra-help" />
|
||||
</span>
|
||||
</h2>
|
||||
<div role="group" aria-labelledby="label-status-messages">
|
||||
<label class="opt">
|
||||
<input
|
||||
:checked="$store.state.settings.statusMessages === 'shown'"
|
||||
type="radio"
|
||||
name="statusMessages"
|
||||
value="shown"
|
||||
/>
|
||||
Show all status messages individually
|
||||
</label>
|
||||
<label class="opt">
|
||||
<input
|
||||
:checked="$store.state.settings.statusMessages === 'condensed'"
|
||||
type="radio"
|
||||
name="statusMessages"
|
||||
value="condensed"
|
||||
/>
|
||||
Condense status messages together
|
||||
</label>
|
||||
<label class="opt">
|
||||
<input
|
||||
:checked="$store.state.settings.statusMessages === 'hidden'"
|
||||
type="radio"
|
||||
name="statusMessages"
|
||||
value="hidden"
|
||||
/>
|
||||
Hide all status messages
|
||||
</label>
|
||||
</div>
|
||||
<h2>Visual Aids</h2>
|
||||
<div>
|
||||
<label class="opt">
|
||||
<input
|
||||
:checked="$store.state.settings.coloredNicks"
|
||||
type="checkbox"
|
||||
name="coloredNicks"
|
||||
/>
|
||||
Enable colored nicknames
|
||||
</label>
|
||||
<label class="opt">
|
||||
<input
|
||||
:checked="$store.state.settings.autocomplete"
|
||||
type="checkbox"
|
||||
name="autocomplete"
|
||||
/>
|
||||
Enable autocomplete
|
||||
</label>
|
||||
</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>
|
||||
<input
|
||||
id="nickPostfix"
|
||||
:value="$store.state.settings.nickPostfix"
|
||||
type="text"
|
||||
name="nickPostfix"
|
||||
class="input"
|
||||
placeholder="Nick autocomplete postfix (e.g. ', ')"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<h2>Theme</h2>
|
||||
<div>
|
||||
<label for="theme-select" class="sr-only">Theme</label>
|
||||
<select
|
||||
id="theme-select"
|
||||
:value="$store.state.settings.theme"
|
||||
name="theme"
|
||||
class="input"
|
||||
>
|
||||
<option
|
||||
v-for="theme in $store.state.serverConfiguration.themes"
|
||||
:key="theme.name"
|
||||
:value="theme.name"
|
||||
>
|
||||
{{ theme.displayName }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<template v-if="$store.state.serverConfiguration.prefetch">
|
||||
<h2>Link previews</h2>
|
||||
<div>
|
||||
<label class="opt">
|
||||
<input
|
||||
:checked="$store.state.settings.media"
|
||||
type="checkbox"
|
||||
name="media"
|
||||
/>
|
||||
Auto-expand media
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="opt">
|
||||
<input
|
||||
:checked="$store.state.settings.links"
|
||||
type="checkbox"
|
||||
name="links"
|
||||
/>
|
||||
Auto-expand websites
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="$store.state.settings.advanced && $store.state.serverConfiguration.fileUpload"
|
||||
>
|
||||
<h2>File uploads</h2>
|
||||
<div>
|
||||
<label class="opt">
|
||||
<input
|
||||
:checked="$store.state.settings.uploadCanvas"
|
||||
type="checkbox"
|
||||
name="uploadCanvas"
|
||||
/>
|
||||
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>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="!$store.state.serverConfiguration.public">
|
||||
<h2>Push Notifications</h2>
|
||||
<div>
|
||||
<button
|
||||
id="pushNotifications"
|
||||
type="button"
|
||||
class="btn"
|
||||
:disabled="
|
||||
$store.state.pushNotificationState !== 'supported' &&
|
||||
$store.state.pushNotificationState !== 'subscribed'
|
||||
"
|
||||
@click="onPushButtonClick"
|
||||
>
|
||||
<template v-if="$store.state.pushNotificationState === 'subscribed'">
|
||||
Unsubscribe from push notifications
|
||||
</template>
|
||||
<template v-else-if="$store.state.pushNotificationState === 'loading'">
|
||||
Loading…
|
||||
</template>
|
||||
<template v-else> Subscribe to push notifications </template>
|
||||
</button>
|
||||
<div v-if="$store.state.pushNotificationState === 'nohttps'" class="error">
|
||||
<strong>Warning</strong>: Push notifications are only supported over HTTPS
|
||||
connections.
|
||||
</div>
|
||||
<div v-if="$store.state.pushNotificationState === 'unsupported'" class="error">
|
||||
<strong>Warning</strong>:
|
||||
<span>Push notifications are not supported by your browser.</span>
|
||||
|
||||
<div v-if="isIOS" class="apple-push-unsupported">
|
||||
Safari does
|
||||
<a
|
||||
href="https://bugs.webkit.org/show_bug.cgi?id=182566"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>not support the web push notification specification</a
|
||||
>, and because all browsers on iOS use Safari under the hood, The Lounge
|
||||
is unable to provide push notifications on iOS devices.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<h2>Browser Notifications</h2>
|
||||
<div>
|
||||
<label class="opt">
|
||||
<input
|
||||
id="desktopNotifications"
|
||||
:checked="$store.state.settings.desktopNotifications"
|
||||
type="checkbox"
|
||||
name="desktopNotifications"
|
||||
/>
|
||||
Enable browser notifications<br />
|
||||
<div
|
||||
v-if="$store.state.desktopNotificationState === 'unsupported'"
|
||||
class="error"
|
||||
>
|
||||
<strong>Warning</strong>: Notifications are not supported by your browser.
|
||||
</div>
|
||||
<div
|
||||
v-if="$store.state.desktopNotificationState === 'blocked'"
|
||||
id="warnBlockedDesktopNotifications"
|
||||
class="error"
|
||||
>
|
||||
<strong>Warning</strong>: Notifications are blocked by your browser.
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="opt">
|
||||
<input
|
||||
:checked="$store.state.settings.notification"
|
||||
type="checkbox"
|
||||
name="notification"
|
||||
/>
|
||||
Enable notification sound
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<div class="opt">
|
||||
<button id="play" @click.prevent="playNotification">Play sound</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="$store.state.settings.advanced">
|
||||
<label class="opt">
|
||||
<input
|
||||
:checked="$store.state.settings.notifyAllMessages"
|
||||
type="checkbox"
|
||||
name="notifyAllMessages"
|
||||
/>
|
||||
Enable notification for all messages
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="!$store.state.serverConfiguration.public && $store.state.settings.advanced">
|
||||
<label class="opt">
|
||||
<label for="highlights" class="opt">
|
||||
Custom highlights
|
||||
<span
|
||||
class="tooltipped tooltipped-n tooltipped-no-delay"
|
||||
aria-label="If a message contains any of these comma-separated
|
||||
expressions, it will trigger a highlight."
|
||||
>
|
||||
<button class="extra-help" />
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="highlights"
|
||||
:value="$store.state.settings.highlights"
|
||||
type="text"
|
||||
name="highlights"
|
||||
class="input"
|
||||
placeholder="Comma-separated, e.g.: word, some more words, anotherword"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="!$store.state.serverConfiguration.public && $store.state.settings.advanced">
|
||||
<label class="opt">
|
||||
<label for="highlightExceptions" class="opt">
|
||||
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
|
||||
your nickname or expressions defined in custom highlights."
|
||||
>
|
||||
<button class="extra-help" />
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="highlightExceptions"
|
||||
:value="$store.state.settings.highlightExceptions"
|
||||
type="text"
|
||||
name="highlightExceptions"
|
||||
class="input"
|
||||
placeholder="Comma-separated, e.g.: word, some more words, anotherword"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
!$store.state.serverConfiguration.public &&
|
||||
!$store.state.serverConfiguration.ldapEnabled
|
||||
"
|
||||
id="change-password"
|
||||
role="group"
|
||||
aria-labelledby="label-change-password"
|
||||
>
|
||||
<h2 id="label-change-password">Change password</h2>
|
||||
<div class="password-container">
|
||||
<label for="old_password_input" class="sr-only"> Enter current password </label>
|
||||
<RevealPassword v-slot:default="slotProps">
|
||||
<input
|
||||
id="old_password_input"
|
||||
:type="slotProps.isVisible ? 'text' : 'password'"
|
||||
name="old_password"
|
||||
class="input"
|
||||
placeholder="Enter current password"
|
||||
/>
|
||||
</RevealPassword>
|
||||
</div>
|
||||
<div class="password-container">
|
||||
<label for="new_password_input" class="sr-only">
|
||||
Enter desired new password
|
||||
</label>
|
||||
<RevealPassword v-slot:default="slotProps">
|
||||
<input
|
||||
id="new_password_input"
|
||||
:type="slotProps.isVisible ? 'text' : 'password'"
|
||||
name="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>
|
||||
<RevealPassword v-slot:default="slotProps">
|
||||
<input
|
||||
id="verify_password_input"
|
||||
:type="slotProps.isVisible ? 'text' : 'password'"
|
||||
name="verify_password"
|
||||
class="input"
|
||||
placeholder="Repeat new password"
|
||||
/>
|
||||
</RevealPassword>
|
||||
</div>
|
||||
<div
|
||||
v-if="passwordChangeStatus && passwordChangeStatus.success"
|
||||
class="feedback success"
|
||||
>
|
||||
Successfully updated your password
|
||||
</div>
|
||||
<div
|
||||
v-else-if="passwordChangeStatus && passwordChangeStatus.error"
|
||||
class="feedback error"
|
||||
>
|
||||
{{ passwordErrors[passwordChangeStatus.error] }}
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" class="btn" @click.prevent="changePassword">
|
||||
Change password
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="$store.state.settings.advanced">
|
||||
<h2>Custom Stylesheet</h2>
|
||||
<label for="user-specified-css-input" class="sr-only">
|
||||
Custom stylesheet. You can override any style with CSS here.
|
||||
</label>
|
||||
<textarea
|
||||
id="user-specified-css-input"
|
||||
:value="$store.state.settings.userStyles"
|
||||
class="input"
|
||||
name="userStyles"
|
||||
placeholder="/* You can override any style with CSS here */"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!$store.state.serverConfiguration.public" class="session-list" role="group">
|
||||
<h2>Sessions</h2>
|
||||
|
||||
<h3>Current session</h3>
|
||||
<Session v-if="currentSession" :session="currentSession" />
|
||||
|
||||
<template v-if="activeSessions.length > 0">
|
||||
<h3>Active sessions</h3>
|
||||
<Session
|
||||
v-for="session in activeSessions"
|
||||
:key="session.token"
|
||||
:session="session"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<h3>Other sessions</h3>
|
||||
<p v-if="$store.state.sessions.length === 0">Loading…</p>
|
||||
<p v-else-if="otherSessions.length === 0">
|
||||
<em>You are not currently logged in to any other device.</em>
|
||||
</p>
|
||||
<Session
|
||||
v-for="session in otherSessions"
|
||||
v-else
|
||||
:key="session.token"
|
||||
:session="session"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
textarea#user-specified-css-input {
|
||||
height: 100px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import socket from "../../js/socket";
|
||||
import webpush from "../../js/webpush";
|
||||
import RevealPassword from "../RevealPassword.vue";
|
||||
import Session from "../Session.vue";
|
||||
import SidebarToggle from "../SidebarToggle.vue";
|
||||
|
||||
let installPromptEvent = null;
|
||||
|
||||
window.addEventListener("beforeinstallprompt", (e) => {
|
||||
e.preventDefault();
|
||||
installPromptEvent = e;
|
||||
});
|
||||
|
||||
export default {
|
||||
name: "Settings",
|
||||
components: {
|
||||
RevealPassword,
|
||||
Session,
|
||||
SidebarToggle,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
canRegisterProtocol: false,
|
||||
passwordChangeStatus: null,
|
||||
passwordErrors: {
|
||||
missing_fields: "Please enter a new password",
|
||||
password_mismatch: "Both new password fields must match",
|
||||
password_incorrect:
|
||||
"The current password field does not match your account password",
|
||||
update_failed: "Failed to update your password",
|
||||
},
|
||||
isIOS: navigator.platform.match(/(iPhone|iPod|iPad)/i) || false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
hasInstallPromptEvent() {
|
||||
// TODO: This doesn't hide the button after clicking
|
||||
return installPromptEvent !== null;
|
||||
},
|
||||
currentSession() {
|
||||
return this.$store.state.sessions.find((item) => item.current);
|
||||
},
|
||||
activeSessions() {
|
||||
return this.$store.state.sessions.filter((item) => !item.current && item.active > 0);
|
||||
},
|
||||
otherSessions() {
|
||||
return this.$store.state.sessions.filter((item) => !item.current && !item.active);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
socket.emit("sessions:get");
|
||||
|
||||
// Enable protocol handler registration if supported,
|
||||
// and the network configuration is not locked
|
||||
this.canRegisterProtocol =
|
||||
window.navigator.registerProtocolHandler &&
|
||||
!this.$store.state.serverConfiguration.lockNetwork;
|
||||
},
|
||||
methods: {
|
||||
onChange(event) {
|
||||
const ignore = ["old_password", "new_password", "verify_password"];
|
||||
|
||||
const name = event.target.name;
|
||||
|
||||
if (ignore.includes(name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let value;
|
||||
|
||||
if (event.target.type === "checkbox") {
|
||||
value = event.target.checked;
|
||||
} else {
|
||||
value = event.target.value;
|
||||
}
|
||||
|
||||
this.$store.dispatch("settings/update", {name, value, sync: true});
|
||||
},
|
||||
changePassword() {
|
||||
const allFields = new FormData(this.$refs.settingsForm);
|
||||
const data = {
|
||||
old_password: allFields.get("old_password"),
|
||||
new_password: allFields.get("new_password"),
|
||||
verify_password: allFields.get("verify_password"),
|
||||
};
|
||||
|
||||
if (!data.old_password || !data.new_password || !data.verify_password) {
|
||||
this.passwordChangeStatus = {
|
||||
success: false,
|
||||
error: "missing_fields",
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.new_password !== data.verify_password) {
|
||||
this.passwordChangeStatus = {
|
||||
success: false,
|
||||
error: "password_mismatch",
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
socket.once("change-password", (response) => {
|
||||
this.passwordChangeStatus = response;
|
||||
});
|
||||
|
||||
socket.emit("change-password", data);
|
||||
},
|
||||
onForceSyncClick() {
|
||||
this.$store.dispatch("settings/syncAll", true);
|
||||
this.$store.dispatch("settings/update", {
|
||||
name: "syncSettings",
|
||||
value: true,
|
||||
sync: true,
|
||||
});
|
||||
},
|
||||
registerProtocol() {
|
||||
const uri = document.location.origin + document.location.pathname + "?uri=%s";
|
||||
|
||||
window.navigator.registerProtocolHandler("irc", uri, "The Lounge");
|
||||
window.navigator.registerProtocolHandler("ircs", uri, "The Lounge");
|
||||
},
|
||||
nativeInstallPrompt() {
|
||||
installPromptEvent.prompt();
|
||||
installPromptEvent = null;
|
||||
},
|
||||
playNotification() {
|
||||
const pop = new Audio();
|
||||
pop.src = "audio/pop.wav";
|
||||
pop.play();
|
||||
},
|
||||
onPushButtonClick() {
|
||||
webpush.togglePushSubscription();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
105
client/components/Windows/SignIn.vue
Normal file
@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div id="sign-in" class="window" role="tabpanel" aria-label="Sign-in">
|
||||
<form class="container" method="post" action="" @submit="onSubmit">
|
||||
<img
|
||||
src="img/logo-vertical-transparent-bg.svg"
|
||||
class="logo"
|
||||
alt="The Lounge"
|
||||
width="256"
|
||||
height="170"
|
||||
/>
|
||||
<img
|
||||
src="img/logo-vertical-transparent-bg-inverted.svg"
|
||||
class="logo-inverted"
|
||||
alt="The Lounge"
|
||||
width="256"
|
||||
height="170"
|
||||
/>
|
||||
|
||||
<label for="signin-username">Username</label>
|
||||
<input
|
||||
id="signin-username"
|
||||
ref="username"
|
||||
class="input"
|
||||
type="text"
|
||||
name="username"
|
||||
autocapitalize="none"
|
||||
autocorrect="off"
|
||||
autocomplete="username"
|
||||
:value="getStoredUser()"
|
||||
required
|
||||
autofocus
|
||||
/>
|
||||
|
||||
<div class="password-container">
|
||||
<label for="signin-password">Password</label>
|
||||
<RevealPassword v-slot:default="slotProps">
|
||||
<input
|
||||
id="signin-password"
|
||||
ref="password"
|
||||
:type="slotProps.isVisible ? 'text' : 'password'"
|
||||
name="password"
|
||||
class="input"
|
||||
autocapitalize="none"
|
||||
autocorrect="off"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
</RevealPassword>
|
||||
</div>
|
||||
|
||||
<div v-if="errorShown" class="error">Authentication failed.</div>
|
||||
|
||||
<button :disabled="inFlight" type="submit" class="btn">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import storage from "../../js/localStorage";
|
||||
import socket from "../../js/socket";
|
||||
import RevealPassword from "../RevealPassword.vue";
|
||||
|
||||
export default {
|
||||
name: "SignIn",
|
||||
components: {
|
||||
RevealPassword,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
inFlight: false,
|
||||
errorShown: false,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
socket.on("auth:failed", this.onAuthFailed);
|
||||
},
|
||||
beforeDestroy() {
|
||||
socket.off("auth:failed", this.onAuthFailed);
|
||||
},
|
||||
methods: {
|
||||
onAuthFailed() {
|
||||
this.inFlight = false;
|
||||
this.errorShown = true;
|
||||
},
|
||||
onSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
this.inFlight = true;
|
||||
this.errorShown = false;
|
||||
|
||||
const values = {
|
||||
user: this.$refs.username.value,
|
||||
password: this.$refs.password.value,
|
||||
};
|
||||
|
||||
storage.set("user", values.user);
|
||||
|
||||
socket.emit("auth:perform", values);
|
||||
},
|
||||
getStoredUser() {
|
||||
return storage.get("user");
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
9
client/css/fontawesome.css
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
@font-face {
|
||||
/* We use free solid icons - https://fontawesome.com/icons?s=solid&m=free */
|
||||
font-family: "FontAwesome";
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
src:
|
||||
url("../fonts/fa-solid-900.woff2") format("woff2"),
|
||||
url("../fonts/fa-solid-900.woff") format("woff");
|
||||
}
|
2849
client/css/style.css
Normal file
BIN
client/favicon.ico
Normal file
After Width: | Height: | Size: 22 KiB |
BIN
client/img/amsDfxn.png
Normal file
After Width: | Height: | Size: 420 KiB |
BIN
client/img/favicon-alerted.ico
Normal file
After Width: | Height: | Size: 22 KiB |
BIN
client/img/icon-alerted-black-transparent-bg-72x72px.png
Normal file
After Width: | Height: | Size: 661 B |
BIN
client/img/icon-alerted-grey-bg-192x192px.png
Normal file
After Width: | Height: | Size: 2.0 KiB |
1
client/img/icon-black-transparent-bg.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><style>.st1{fill:black}</style><path d="M47.1 23.5v17.1c0 .1-.1.3-.2.4l-14.7 8.5c-.1.1-.3.1-.4 0L17.1 41c-.1-.1-.2-.2-.2-.4v-15c0-.1-.1-.3-.2-.4l-4-2.3c-.3-.2-.6 0-.6.4v19c0 .9.5 1.7 1.2 2.1l17.6 10.1c.8.4 1.7.4 2.5 0l17.5-10.1c.8-.4 1.2-1.3 1.2-2.1V21.8c0-.9-.5-1.7-1.2-2.1L33.3 9.6c-.8-.4-1.7-.4-2.5 0l-8.1 4.7c-.3.2-.3.6 0 .7l4.1 2.3c.1.1.3.1.4 0l4.7-2.7c.1-.1.3-.1.4 0L47 23.1c0 .1.1.2.1.4z" fill="black"/><circle class="st1" cx="40.3" cy="32.1" r="2.8"/><circle class="st1" cx="31.5" cy="32.1" r="2.8"/></svg>
|
After Width: | Height: | Size: 575 B |
BIN
client/img/logo-grey-bg-120x120px.png
Normal file
After Width: | Height: | Size: 960 B |
BIN
client/img/logo-grey-bg-152x152px.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
client/img/logo-grey-bg-167x167px.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
client/img/logo-grey-bg-180x180px.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
BIN
client/img/logo-grey-bg-192x192px.png
Normal file
After Width: | Height: | Size: 1.4 KiB |
BIN
client/img/logo-grey-bg-512x512px.png
Normal file
After Width: | Height: | Size: 4.8 KiB |
1
client/img/logo-grey-bg.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 386 386"><style>.st1{fill:#ff9e18}.st2{fill:#fff}</style><path fill="#415364" d="M0 0h386v386H0z"/><g transform="translate(0 55)"><path class="st1" d="M320.1 100v76.1c0 .7-.4 1.3-.9 1.6l-65.8 37.7c-.6.3-1.3.3-1.8 0l-66.1-38c-.6-.3-.9-.9-.9-1.6V109c0-.7-.4-1.3-.9-1.6L165.5 97c-1.2-.7-2.8.2-2.8 1.6v84.5c0 3.9 2.1 7.6 5.5 9.5l78.7 45.1c3.4 2 7.6 2 11.1 0l78.4-44.9c3.4-2 5.5-5.6 5.5-9.5V92.7c0-3.9-2.1-7.6-5.5-9.5L258 38.3c-3.4-2-7.6-2-11.1 0l-36.3 20.8c-1.2.7-1.2 2.5 0 3.2l18.2 10.4c.6.3 1.3.3 1.8 0l20.9-12c.6-.3 1.3-.3 1.8 0l65.8 37.7c.7.3 1 .9 1 1.6z"/><ellipse class="st2" cx="289.6" cy="138.4" rx="12.4" ry="12.4"/><ellipse class="st2" cx="249.9" cy="138.4" rx="12.4" ry="12.4"/></g><g transform="translate(0 55)"><path class="st2" d="M64.6 176V99.9c0-.7.4-1.3.9-1.6l65.8-37.7c.6-.3 1.3-.3 1.8 0l66.1 38c.6.3.9.9.9 1.6V167c0 .7.4 1.3.9 1.6l18.1 10.4c1.2.7 2.8-.2 2.8-1.6V92.9c0-3.9-2.1-7.6-5.5-9.5l-78.7-45.1c-3.4-2-7.6-2-11.1 0L48.3 83.1c-3.4 2-5.5 5.6-5.5 9.5v90.6c0 3.9 2.1 7.6 5.5 9.5l78.4 44.9c3.4 2 7.6 2 11.1 0l36.3-20.8c1.2-.7 1.2-2.5 0-3.2l-18.2-10.4c-.6-.3-1.3-.3-1.8 0l-20.9 12c-.6.3-1.3.3-1.8 0l-65.8-37.7c-.6-.2-1-.8-1-1.5z"/><ellipse class="st1" cx="95.2" cy="137.6" rx="12.4" ry="12.4"/><ellipse class="st1" cx="134.9" cy="137.6" rx="12.4" ry="12.4"/></g></svg>
|
After Width: | Height: | Size: 1.3 KiB |
1
client/img/logo-horizontal-transparent-bg-inverted.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 838 276"><style>.st0{fill:#ff9e18}.st1{fill:#fff}</style><path class="st0" d="M319.5 100v76.1c0 .7-.4 1.3-.9 1.6l-65.8 37.7c-.6.3-1.3.3-1.8 0l-66.1-38c-.6-.3-.9-.9-.9-1.6V109c0-.7-.4-1.3-.9-1.6L164.9 97c-1.2-.7-2.8.2-2.8 1.6v84.5c0 3.9 2.1 7.6 5.5 9.5l78.7 45.1c3.4 2 7.6 2 11.1 0l78.4-44.9c3.4-2 5.5-5.6 5.5-9.5V92.7c0-3.9-2.1-7.6-5.5-9.5l-78.4-44.9c-3.4-2-7.6-2-11.1 0L210 59.1c-1.2.7-1.2 2.5 0 3.2l18.2 10.4c.6.3 1.3.3 1.8 0l20.9-12c.6-.3 1.3-.3 1.8 0l65.8 37.7c.7.3 1 .9 1 1.6z"/><ellipse class="st1" cx="288.9" cy="138.4" rx="12.4" ry="12.4"/><ellipse class="st1" cx="249.3" cy="138.4" rx="12.4" ry="12.4"/><path class="st1" d="M64 176V99.9c0-.7.4-1.3.9-1.6l65.8-37.7c.6-.3 1.3-.3 1.8 0l66.1 38c.6.3.9.9.9 1.6V167c0 .7.4 1.3.9 1.6l18.1 10.4c1.2.7 2.8-.2 2.8-1.6V92.9c0-3.9-2.1-7.6-5.5-9.5l-78.7-45.1c-3.4-2-7.6-2-11.1 0L47.7 83.1c-3.4 2-5.5 5.6-5.5 9.5v90.6c0 3.9 2.1 7.6 5.5 9.5l78.4 44.9c3.4 2 7.6 2 11.1 0l36.3-20.8c1.2-.7 1.2-2.5 0-3.2l-18.2-10.4c-.6-.3-1.3-.3-1.8 0l-20.9 12c-.6.3-1.3.3-1.8 0L65 177.5c-.7-.2-1-.8-1-1.5z"/><ellipse class="st0" cx="94.6" cy="137.6" rx="12.4" ry="12.4"/><ellipse class="st0" cx="134.2" cy="137.6" rx="12.4" ry="12.4"/><path class="st0" d="M362.9 121.4h15.2v40.8h11.5v-40.8h15.3v-9.5h-42zm83.6 10.6h-22.1v-20.1h-11.2v50.4h11.2v-20.9h22.1v20.9h11.2v-50.4h-11.2zm54.2-11v-9.1h-34.5v50.4h34.5v-9.1h-23.3v-11.9h21.8v-9.2h-21.8V121z"/><path class="st1" d="M520.6 111.9h-11.4v50.4h33.3v-9.5h-21.9zm65.2 3.1c-3.8-2.1-8.4-3.1-13.6-3.1-5.2 0-9.8 1-13.6 3.1-3.9 2.1-6.9 5-9 8.8-2.1 3.8-3.1 8.3-3.1 13.4s1.1 9.6 3.1 13.4c2.1 3.8 5.1 6.8 9 8.9 3.9 2.1 8.4 3.1 13.6 3.1 5.2 0 9.8-1 13.6-3.1 3.8-2.1 6.8-5 8.9-8.9 2.1-3.8 3.1-8.3 3.1-13.4s-1-9.6-3.1-13.4c-2.1-3.8-5.1-6.7-8.9-8.8zm0 22.2c0 5.3-1.2 9.4-3.6 12.2-2.4 2.8-5.7 4.2-10 4.2s-7.6-1.4-10-4.2c-2.4-2.8-3.6-6.9-3.6-12.2 0-5.3 1.2-9.4 3.6-12.1 2.4-2.7 5.7-4.1 10-4.1 4.2 0 7.6 1.4 10 4.1 2.4 2.7 3.6 6.8 3.6 12.1zm51.9 4.4c0 3.8-.9 6.7-2.6 8.7-1.7 2-4.3 2.9-7.6 2.9s-5.9-1-7.6-2.9c-1.8-2-2.6-4.9-2.6-8.7V112H606v29.1c0 6.9 1.8 12.2 5.5 15.8 3.6 3.6 9 5.4 16 5.4 6.9 0 12.3-1.8 16-5.4 3.7-3.6 5.5-8.9 5.5-15.8V112h-11.3v29.6zm52.4 1.3l-24.3-31h-8.6v50.4h10.9v-31l24.2 30.9.1.1h8.6v-50.4h-10.9zm43.3-.5h9.6v10.1c-2.6.6-5.2.9-7.8.9-9.7 0-14.5-5.3-14.5-16.3 0-10.8 4.5-16 13.8-16 2.5 0 4.8.4 6.9 1.1 2.1.7 4.3 1.9 6.6 3.5l.3.2 3.7-8.2-.2-.2c-2-1.7-4.6-3.1-7.7-4.1-3.1-1-6.4-1.4-10-1.4-5 0-9.5 1-13.3 3.1-3.8 2-6.7 5-8.8 8.7-2.1 3.8-3.1 8.2-3.1 13.3 0 5.2 1 9.7 3.1 13.5 2.1 3.8 5.1 6.7 8.9 8.7 3.8 2 8.4 3 13.6 3 3.4 0 6.7-.3 9.9-1 3.2-.6 6-1.5 8.4-2.7l.2-.1v-24.3h-19.9v8.2zm62.4-21.4v-9.1h-33.9v50.5h33.9v-9.2h-22.9v-11.9h21.5v-9.2h-21.5V121z"/></svg>
|
After Width: | Height: | Size: 2.6 KiB |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 838 276"><style>.st0{fill:#ff9e18}.st1{fill:#fff}</style><path class="st0" d="M319.5 100v76.1c0 .7-.4 1.3-.9 1.6l-65.8 37.7c-.6.3-1.3.3-1.8 0l-66.1-38c-.6-.3-.9-.9-.9-1.6V109c0-.7-.4-1.3-.9-1.6L164.9 97c-1.2-.7-2.8.2-2.8 1.6v84.5c0 3.9 2.1 7.6 5.5 9.5l78.7 45.1c3.4 2 7.6 2 11.1 0l78.4-44.9c3.4-2 5.5-5.6 5.5-9.5V92.7c0-3.9-2.1-7.6-5.5-9.5l-78.4-44.9c-3.4-2-7.6-2-11.1 0L210 59.1c-1.2.7-1.2 2.5 0 3.2l18.2 10.4c.6.3 1.3.3 1.8 0l20.9-12c.6-.3 1.3-.3 1.8 0l65.8 37.7c.7.3 1 .9 1 1.6z"/><ellipse class="st1" cx="288.9" cy="138.4" rx="12.4" ry="12.4"/><ellipse class="st1" cx="249.3" cy="138.4" rx="12.4" ry="12.4"/><path class="st1" d="M64 176V99.9c0-.7.4-1.3.9-1.6l65.8-37.7c.6-.3 1.3-.3 1.8 0l66.1 38c.6.3.9.9.9 1.6V167c0 .7.4 1.3.9 1.6l18.1 10.4c1.2.7 2.8-.2 2.8-1.6V92.9c0-3.9-2.1-7.6-5.5-9.5l-78.7-45.1c-3.4-2-7.6-2-11.1 0L47.7 83.1c-3.4 2-5.5 5.6-5.5 9.5v90.6c0 3.9 2.1 7.6 5.5 9.5l78.4 44.9c3.4 2 7.6 2 11.1 0l36.3-20.8c1.2-.7 1.2-2.5 0-3.2l-18.2-10.4c-.6-.3-1.3-.3-1.8 0l-20.9 12c-.6.3-1.3.3-1.8 0L65 177.5c-.7-.2-1-.8-1-1.5z"/><ellipse class="st0" cx="94.6" cy="137.6" rx="12.4" ry="12.4"/><ellipse class="st0" cx="134.2" cy="137.6" rx="12.4" ry="12.4"/><path class="st0" d="M362.9 121.4h15.2v40.8h11.5v-40.8h15.3v-9.5h-42zm83.6 10.6h-22.1v-20.1h-11.2v50.4h11.2v-20.9h22.1v20.9h11.2v-50.4h-11.2zm54.2-11v-9.1h-34.5v50.4h34.5v-9.1h-23.3v-11.9h21.8v-9.2h-21.8V121z"/><path class="st1" d="M520.6 111.9h-11.4v50.4h33.3v-9.5h-21.9zm65.2 3.1c-3.8-2.1-8.4-3.1-13.6-3.1-5.2 0-9.8 1-13.6 3.1-3.9 2.1-6.9 5-9 8.8-2.1 3.8-3.1 8.3-3.1 13.4s1.1 9.6 3.1 13.4c2.1 3.8 5.1 6.8 9 8.9 3.9 2.1 8.4 3.1 13.6 3.1 5.2 0 9.8-1 13.6-3.1 3.8-2.1 6.8-5 8.9-8.9 2.1-3.8 3.1-8.3 3.1-13.4s-1-9.6-3.1-13.4c-2.1-3.8-5.1-6.7-8.9-8.8zm0 22.2c0 5.3-1.2 9.4-3.6 12.2-2.4 2.8-5.7 4.2-10 4.2s-7.6-1.4-10-4.2c-2.4-2.8-3.6-6.9-3.6-12.2 0-5.3 1.2-9.4 3.6-12.1 2.4-2.7 5.7-4.1 10-4.1 4.2 0 7.6 1.4 10 4.1 2.4 2.7 3.6 6.8 3.6 12.1zm51.9 4.4c0 3.8-.9 6.7-2.6 8.7-1.7 2-4.3 2.9-7.6 2.9s-5.9-1-7.6-2.9c-1.8-2-2.6-4.9-2.6-8.7V112H606v29.1c0 6.9 1.8 12.2 5.5 15.8 3.6 3.6 9 5.4 16 5.4 6.9 0 12.3-1.8 16-5.4 3.7-3.6 5.5-8.9 5.5-15.8V112h-11.3v29.6zm52.4 1.3l-24.3-31h-8.6v50.4h10.9v-31l24.2 30.9.1.1h8.6v-50.4h-10.9zm43.3-.5h9.6v10.1c-2.6.6-5.2.9-7.8.9-9.7 0-14.5-5.3-14.5-16.3 0-10.8 4.5-16 13.8-16 2.5 0 4.8.4 6.9 1.1 2.1.7 4.3 1.9 6.6 3.5l.3.2 3.7-8.2-.2-.2c-2-1.7-4.6-3.1-7.7-4.1-3.1-1-6.4-1.4-10-1.4-5 0-9.5 1-13.3 3.1-3.8 2-6.7 5-8.8 8.7-2.1 3.8-3.1 8.2-3.1 13.3 0 5.2 1 9.7 3.1 13.5 2.1 3.8 5.1 6.7 8.9 8.7 3.8 2 8.4 3 13.6 3 3.4 0 6.7-.3 9.9-1 3.2-.6 6-1.5 8.4-2.7l.2-.1v-24.3h-19.9v8.2zm62.4-21.4v-9.1h-33.9v50.5h33.9v-9.2h-22.9v-11.9h21.5v-9.2h-21.5V121z"/></svg>
|
After Width: | Height: | Size: 2.6 KiB |
BIN
client/img/logo-horizontal-transparent-bg.svg
Normal file
After Width: | Height: | Size: 420 KiB |
1
client/img/logo-horizontal-transparent-bg.svg.orig
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 838 276"><style>.st0{fill:#ff9e18}.st1{fill:#415364}</style><path class="st0" d="M319.5 100v76.1c0 .7-.4 1.3-.9 1.6l-65.8 37.7c-.6.3-1.3.3-1.8 0l-66.1-38c-.6-.3-.9-.9-.9-1.6V109c0-.7-.4-1.3-.9-1.6L164.9 97c-1.2-.7-2.8.2-2.8 1.6v84.5c0 3.9 2.1 7.6 5.5 9.5l78.7 45.1c3.4 2 7.6 2 11.1 0l78.4-44.9c3.4-2 5.5-5.6 5.5-9.5V92.7c0-3.9-2.1-7.6-5.5-9.5l-78.4-44.9c-3.4-2-7.6-2-11.1 0L210 59.1c-1.2.7-1.2 2.5 0 3.2l18.2 10.4c.6.3 1.3.3 1.8 0l20.9-12c.6-.3 1.3-.3 1.8 0l65.8 37.7c.7.3 1 .9 1 1.6z"/><ellipse class="st1" cx="288.9" cy="138.4" rx="12.4" ry="12.4"/><ellipse class="st1" cx="249.3" cy="138.4" rx="12.4" ry="12.4"/><path class="st1" d="M64 176V99.9c0-.7.4-1.3.9-1.6l65.8-37.7c.6-.3 1.3-.3 1.8 0l66.1 38c.6.3.9.9.9 1.6V167c0 .7.4 1.3.9 1.6l18.1 10.4c1.2.7 2.8-.2 2.8-1.6V92.9c0-3.9-2.1-7.6-5.5-9.5l-78.7-45.1c-3.4-2-7.6-2-11.1 0L47.7 83.1c-3.4 2-5.5 5.6-5.5 9.5v90.6c0 3.9 2.1 7.6 5.5 9.5l78.4 44.9c3.4 2 7.6 2 11.1 0l36.3-20.8c1.2-.7 1.2-2.5 0-3.2l-18.2-10.4c-.6-.3-1.3-.3-1.8 0l-20.9 12c-.6.3-1.3.3-1.8 0L65 177.5c-.7-.2-1-.8-1-1.5z"/><ellipse class="st0" cx="94.6" cy="137.6" rx="12.4" ry="12.4"/><ellipse class="st0" cx="134.2" cy="137.6" rx="12.4" ry="12.4"/><path class="st0" d="M362.9 121.4h15.2v40.8h11.5v-40.8h15.3v-9.5h-42zm83.6 10.6h-22.1v-20.1h-11.2v50.4h11.2v-20.9h22.1v20.9h11.2v-50.4h-11.2zm54.2-11v-9.1h-34.5v50.4h34.5v-9.1h-23.3v-11.9h21.8v-9.2h-21.8V121z"/><path class="st1" d="M520.6 111.9h-11.4v50.4h33.3v-9.5h-21.9zm65.2 3.1c-3.8-2.1-8.4-3.1-13.6-3.1-5.2 0-9.8 1-13.6 3.1-3.9 2.1-6.9 5-9 8.8-2.1 3.8-3.1 8.3-3.1 13.4s1.1 9.6 3.1 13.4c2.1 3.8 5.1 6.8 9 8.9 3.9 2.1 8.4 3.1 13.6 3.1 5.2 0 9.8-1 13.6-3.1 3.8-2.1 6.8-5 8.9-8.9 2.1-3.8 3.1-8.3 3.1-13.4s-1-9.6-3.1-13.4c-2.1-3.8-5.1-6.7-8.9-8.8zm0 22.2c0 5.3-1.2 9.4-3.6 12.2-2.4 2.8-5.7 4.2-10 4.2s-7.6-1.4-10-4.2c-2.4-2.8-3.6-6.9-3.6-12.2 0-5.3 1.2-9.4 3.6-12.1 2.4-2.7 5.7-4.1 10-4.1 4.2 0 7.6 1.4 10 4.1 2.4 2.7 3.6 6.8 3.6 12.1zm51.9 4.4c0 3.8-.9 6.7-2.6 8.7-1.7 2-4.3 2.9-7.6 2.9s-5.9-1-7.6-2.9c-1.8-2-2.6-4.9-2.6-8.7V112H606v29.1c0 6.9 1.8 12.2 5.5 15.8 3.6 3.6 9 5.4 16 5.4 6.9 0 12.3-1.8 16-5.4 3.7-3.6 5.5-8.9 5.5-15.8V112h-11.3v29.6zm52.4 1.3l-24.3-31h-8.6v50.4h10.9v-31l24.2 30.9.1.1h8.6v-50.4h-10.9zm43.3-.5h9.6v10.1c-2.6.6-5.2.9-7.8.9-9.7 0-14.5-5.3-14.5-16.3 0-10.8 4.5-16 13.8-16 2.5 0 4.8.4 6.9 1.1 2.1.7 4.3 1.9 6.6 3.5l.3.2 3.7-8.2-.2-.2c-2-1.7-4.6-3.1-7.7-4.1-3.1-1-6.4-1.4-10-1.4-5 0-9.5 1-13.3 3.1-3.8 2-6.7 5-8.8 8.7-2.1 3.8-3.1 8.2-3.1 13.3 0 5.2 1 9.7 3.1 13.5 2.1 3.8 5.1 6.7 8.9 8.7 3.8 2 8.4 3 13.6 3 3.4 0 6.7-.3 9.9-1 3.2-.6 6-1.5 8.4-2.7l.2-.1v-24.3h-19.9v8.2zm62.4-21.4v-9.1h-33.9v50.5h33.9v-9.2h-22.9v-11.9h21.5v-9.2h-21.5V121z"/></svg>
|
After Width: | Height: | Size: 2.6 KiB |
1
client/img/logo-transparent-bg-inverted.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 386 276"><style>.st0{fill:#ff9e18}.st1{fill:#fff}</style><path class="st0" d="M320.1 100v76.1c0 .7-.4 1.3-.9 1.6l-65.8 37.7c-.6.3-1.3.3-1.8 0l-66.1-38c-.6-.3-.9-.9-.9-1.6V109c0-.7-.4-1.3-.9-1.6L165.5 97c-1.2-.7-2.8.2-2.8 1.6v84.5c0 3.9 2.1 7.6 5.5 9.5l78.7 45.1c3.4 2 7.6 2 11.1 0l78.4-44.9c3.4-2 5.5-5.6 5.5-9.5V92.7c0-3.9-2.1-7.6-5.5-9.5L258 38.3c-3.4-2-7.6-2-11.1 0l-36.3 20.8c-1.2.7-1.2 2.5 0 3.2l18.2 10.4c.6.3 1.3.3 1.8 0l20.9-12c.6-.3 1.3-.3 1.8 0l65.8 37.7c.7.3 1 .9 1 1.6z"/><ellipse class="st1" cx="289.6" cy="138.4" rx="12.4" ry="12.4"/><ellipse class="st1" cx="249.9" cy="138.4" rx="12.4" ry="12.4"/><path class="st1" d="M64.6 176V99.9c0-.7.4-1.3.9-1.6l65.8-37.7c.6-.3 1.3-.3 1.8 0l66.1 38c.6.3.9.9.9 1.6V167c0 .7.4 1.3.9 1.6l18.1 10.4c1.2.7 2.8-.2 2.8-1.6V92.9c0-3.9-2.1-7.6-5.5-9.5l-78.7-45.1c-3.4-2-7.6-2-11.1 0L48.3 83.1c-3.4 2-5.5 5.6-5.5 9.5v90.6c0 3.9 2.1 7.6 5.5 9.5l78.4 44.9c3.4 2 7.6 2 11.1 0l36.3-20.8c1.2-.7 1.2-2.5 0-3.2l-18.2-10.4c-.6-.3-1.3-.3-1.8 0l-20.9 12c-.6.3-1.3.3-1.8 0l-65.8-37.7c-.6-.2-1-.8-1-1.5z"/><ellipse class="st0" cx="95.2" cy="137.6" rx="12.4" ry="12.4"/><ellipse class="st0" cx="134.9" cy="137.6" rx="12.4" ry="12.4"/></svg>
|
After Width: | Height: | Size: 1.2 KiB |
1
client/img/logo-transparent-bg.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 386 276"><style>.st0{fill:#ff9e18}.st1{fill:#415364}</style><path class="st0" d="M320.1 100v76.1c0 .7-.4 1.3-.9 1.6l-65.8 37.7c-.6.3-1.3.3-1.8 0l-66.1-38c-.6-.3-.9-.9-.9-1.6V109c0-.7-.4-1.3-.9-1.6L165.5 97c-1.2-.7-2.8.2-2.8 1.6v84.5c0 3.9 2.1 7.6 5.5 9.5l78.7 45.1c3.4 2 7.6 2 11.1 0l78.4-44.9c3.4-2 5.5-5.6 5.5-9.5V92.7c0-3.9-2.1-7.6-5.5-9.5L258 38.3c-3.4-2-7.6-2-11.1 0l-36.3 20.8c-1.2.7-1.2 2.5 0 3.2l18.2 10.4c.6.3 1.3.3 1.8 0l20.9-12c.6-.3 1.3-.3 1.8 0l65.8 37.7c.7.3 1 .9 1 1.6z"/><ellipse class="st1" cx="289.6" cy="138.4" rx="12.4" ry="12.4"/><ellipse class="st1" cx="249.9" cy="138.4" rx="12.4" ry="12.4"/><path class="st1" d="M64.6 176V99.9c0-.7.4-1.3.9-1.6l65.8-37.7c.6-.3 1.3-.3 1.8 0l66.1 38c.6.3.9.9.9 1.6V167c0 .7.4 1.3.9 1.6l18.1 10.4c1.2.7 2.8-.2 2.8-1.6V92.9c0-3.9-2.1-7.6-5.5-9.5l-78.7-45.1c-3.4-2-7.6-2-11.1 0L48.3 83.1c-3.4 2-5.5 5.6-5.5 9.5v90.6c0 3.9 2.1 7.6 5.5 9.5l78.4 44.9c3.4 2 7.6 2 11.1 0l36.3-20.8c1.2-.7 1.2-2.5 0-3.2l-18.2-10.4c-.6-.3-1.3-.3-1.8 0l-20.9 12c-.6.3-1.3.3-1.8 0l-65.8-37.7c-.6-.2-1-.8-1-1.5z"/><ellipse class="st0" cx="95.2" cy="137.6" rx="12.4" ry="12.4"/><ellipse class="st0" cx="134.9" cy="137.6" rx="12.4" ry="12.4"/></svg>
|
After Width: | Height: | Size: 1.2 KiB |
1
client/img/logo-vertical-transparent-bg-inverted.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 412 276"><style>.st0{fill:#ff9e18}.st1{fill:#fff}</style><path class="st0" d="M314.5 75.6v63.7c0 .5-.3 1.1-.8 1.3l-56 31.6c-.5.3-1.1.3-1.6 0l-56.4-31.8c-.5-.3-.8-.8-.8-1.3V83.2c0-.5-.3-1.1-.8-1.3l-15.5-8.7c-1-.6-2.4.1-2.4 1.3v70.8c0 3.3 1.8 6.3 4.7 8L252 191c2.9 1.6 6.5 1.6 9.4 0l66.8-37.6c2.9-1.6 4.7-4.7 4.7-8V69.5c0-3.3-1.8-6.3-4.7-8L261.6 24c-2.9-1.6-6.5-1.6-9.4 0l-30.9 17.4c-1 .6-1 2.1 0 2.7l15.5 8.7c.5.3 1.1.3 1.6 0l17.8-10c.5-.3 1.1-.3 1.6 0l56.1 31.5c.3.2.6.7.6 1.3z"/><ellipse class="st1" cx="288.4" cy="107.7" rx="10.6" ry="10.4"/><ellipse class="st1" cx="254.6" cy="107.7" rx="10.6" ry="10.4"/><path class="st1" d="M96.7 139.2V75.5c0-.5.3-1.1.8-1.3l56-31.6c.5-.3 1.1-.3 1.6 0l56.4 31.8c.5.3.8.8.8 1.3v55.9c0 .5.3 1.1.8 1.3l15.5 8.7c1 .6 2.4-.1 2.4-1.3V69.6c0-3.3-1.8-6.3-4.7-8L159 23.9c-2.9-1.6-6.5-1.6-9.4 0L82.8 61.5c-2.9 1.6-4.7 4.7-4.7 8v75.8c0 3.3 1.8 6.3 4.7 8l66.8 37.6c2.9 1.6 6.5 1.6 9.4 0l30.9-17.4c1-.6 1-2.1 0-2.7l-15.5-8.7c-.5-.3-1.1-.3-1.6 0l-17.8 10c-.5.3-1.1.3-1.6 0l-56.1-31.5c-.3-.3-.6-.8-.6-1.4z"/><ellipse class="st0" cx="122.8" cy="107.1" rx="10.6" ry="10.4"/><ellipse class="st0" cx="156.6" cy="107.1" rx="10.6" ry="10.4"/><path class="st0" d="M21.5 218.9h13V253h9.8v-34.1h13v-8H21.5zm71.3 8.8H74v-16.8h-9.6v42.2H74v-17.5h18.8v17.5h9.5v-42.2h-9.5zm46.2-9.2v-7.6h-29.4v42.2H139v-7.6h-19.9v-10h18.6v-7.7h-18.6v-9.3z"/><path class="st1" d="M156 210.9h-9.8v42.2h28.4v-8H156zm55.5 2.6c-3.3-1.7-7.2-2.6-11.6-2.6-4.4 0-8.3.9-11.6 2.6-3.3 1.7-5.9 4.2-7.6 7.4-1.8 3.2-2.7 6.9-2.7 11.2 0 4.3.9 8.1 2.7 11.3 1.8 3.2 4.3 5.7 7.6 7.4 3.3 1.7 7.2 2.6 11.6 2.6 4.4 0 8.3-.9 11.6-2.6 3.3-1.7 5.8-4.2 7.6-7.4 1.8-3.2 2.6-7 2.6-11.3 0-4.3-.9-8-2.6-11.2-1.8-3.2-4.3-5.7-7.6-7.4zm0 18.5c0 4.4-1 7.9-3.1 10.2-2 2.3-4.9 3.5-8.5 3.5-3.6 0-6.5-1.2-8.5-3.5s-3.1-5.8-3.1-10.2c0-4.4 1-7.8 3.1-10.1 2-2.3 4.9-3.5 8.5-3.5 3.6 0 6.5 1.2 8.5 3.5 2.1 2.3 3.1 5.7 3.1 10.1zm44.2 3.8c0 3.2-.7 5.6-2.2 7.3-1.5 1.6-3.6 2.5-6.4 2.5-2.8 0-5-.8-6.5-2.5-1.5-1.6-2.3-4.1-2.3-7.3V211h-9.6v24.4c0 5.8 1.6 10.2 4.7 13.2s7.7 4.5 13.6 4.5c5.9 0 10.5-1.5 13.6-4.5 3.1-3 4.7-7.5 4.7-13.2V211h-9.7v24.8zm44.7 1l-20.8-25.9h-7.2v42.2h9.2v-26l20.7 25.9.1.1h7.3v-42.2h-9.3zm36.9-.4h8.2v8.4c-2.2.5-4.4.8-6.6.8-8.3 0-12.3-4.5-12.3-13.7 0-9 3.9-13.4 11.8-13.4 2.1 0 4.1.3 5.9.9 1.8.6 3.7 1.6 5.6 2.9l.3.2 3.2-6.9-.2-.1c-1.7-1.5-3.9-2.6-6.5-3.4-2.6-.8-5.5-1.2-8.5-1.2-4.3 0-8.1.9-11.3 2.6-3.2 1.7-5.7 4.2-7.5 7.3-1.8 3.1-2.6 6.9-2.6 11.1 0 4.4.9 8.2 2.6 11.3 1.8 3.1 4.3 5.6 7.6 7.3 3.3 1.7 7.2 2.5 11.6 2.5 2.9 0 5.7-.3 8.4-.8s5.2-1.3 7.2-2.2l.2-.1v-20.3h-17v6.8zm53.2-17.9v-7.6h-28.9v42.2h28.9v-7.6H371v-10h18.3v-7.7H371v-9.3z"/></svg>
|
After Width: | Height: | Size: 2.6 KiB |
1
client/img/logo-vertical-transparent-bg.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 412 276"><style>.st0{fill:#ff9e18}.st1{fill:#415364}</style><path class="st0" d="M314.5 75.6v63.7c0 .5-.3 1.1-.8 1.3l-56 31.6c-.5.3-1.1.3-1.6 0l-56.4-31.8c-.5-.3-.8-.8-.8-1.3V83.2c0-.5-.3-1.1-.8-1.3l-15.5-8.7c-1-.6-2.4.1-2.4 1.3v70.8c0 3.3 1.8 6.3 4.7 8L252 191c2.9 1.6 6.5 1.6 9.4 0l66.8-37.6c2.9-1.6 4.7-4.7 4.7-8V69.5c0-3.3-1.8-6.3-4.7-8L261.6 24c-2.9-1.6-6.5-1.6-9.4 0l-30.9 17.4c-1 .6-1 2.1 0 2.7l15.5 8.7c.5.3 1.1.3 1.6 0l17.8-10c.5-.3 1.1-.3 1.6 0l56.1 31.5c.3.2.6.7.6 1.3z"/><ellipse class="st1" cx="288.4" cy="107.7" rx="10.6" ry="10.4"/><ellipse class="st1" cx="254.6" cy="107.7" rx="10.6" ry="10.4"/><path class="st1" d="M96.7 139.2V75.5c0-.5.3-1.1.8-1.3l56-31.6c.5-.3 1.1-.3 1.6 0l56.4 31.8c.5.3.8.8.8 1.3v55.9c0 .5.3 1.1.8 1.3l15.5 8.7c1 .6 2.4-.1 2.4-1.3V69.6c0-3.3-1.8-6.3-4.7-8L159 23.9c-2.9-1.6-6.5-1.6-9.4 0L82.8 61.5c-2.9 1.6-4.7 4.7-4.7 8v75.8c0 3.3 1.8 6.3 4.7 8l66.8 37.6c2.9 1.6 6.5 1.6 9.4 0l30.9-17.4c1-.6 1-2.1 0-2.7l-15.5-8.7c-.5-.3-1.1-.3-1.6 0l-17.8 10c-.5.3-1.1.3-1.6 0l-56.1-31.5c-.3-.3-.6-.8-.6-1.4z"/><ellipse class="st0" cx="122.8" cy="107.1" rx="10.6" ry="10.4"/><ellipse class="st0" cx="156.6" cy="107.1" rx="10.6" ry="10.4"/><path class="st0" d="M21.5 218.9h13V253h9.8v-34.1h13v-8H21.5zm71.3 8.8H74v-16.8h-9.6v42.2H74v-17.5h18.8v17.5h9.5v-42.2h-9.5zm46.2-9.2v-7.6h-29.4v42.2H139v-7.6h-19.9v-10h18.6v-7.7h-18.6v-9.3z"/><path class="st1" d="M156 210.9h-9.8v42.2h28.4v-8H156zm55.5 2.6c-3.3-1.7-7.2-2.6-11.6-2.6-4.4 0-8.3.9-11.6 2.6-3.3 1.7-5.9 4.2-7.6 7.4-1.8 3.2-2.7 6.9-2.7 11.2 0 4.3.9 8.1 2.7 11.3 1.8 3.2 4.3 5.7 7.6 7.4 3.3 1.7 7.2 2.6 11.6 2.6 4.4 0 8.3-.9 11.6-2.6 3.3-1.7 5.8-4.2 7.6-7.4 1.8-3.2 2.6-7 2.6-11.3 0-4.3-.9-8-2.6-11.2-1.8-3.2-4.3-5.7-7.6-7.4zm0 18.5c0 4.4-1 7.9-3.1 10.2-2 2.3-4.9 3.5-8.5 3.5-3.6 0-6.5-1.2-8.5-3.5s-3.1-5.8-3.1-10.2c0-4.4 1-7.8 3.1-10.1 2-2.3 4.9-3.5 8.5-3.5 3.6 0 6.5 1.2 8.5 3.5 2.1 2.3 3.1 5.7 3.1 10.1zm44.2 3.8c0 3.2-.7 5.6-2.2 7.3-1.5 1.6-3.6 2.5-6.4 2.5-2.8 0-5-.8-6.5-2.5-1.5-1.6-2.3-4.1-2.3-7.3V211h-9.6v24.4c0 5.8 1.6 10.2 4.7 13.2s7.7 4.5 13.6 4.5c5.9 0 10.5-1.5 13.6-4.5 3.1-3 4.7-7.5 4.7-13.2V211h-9.7v24.8zm44.7 1l-20.8-25.9h-7.2v42.2h9.2v-26l20.7 25.9.1.1h7.3v-42.2h-9.3zm36.9-.4h8.2v8.4c-2.2.5-4.4.8-6.6.8-8.3 0-12.3-4.5-12.3-13.7 0-9 3.9-13.4 11.8-13.4 2.1 0 4.1.3 5.9.9 1.8.6 3.7 1.6 5.6 2.9l.3.2 3.2-6.9-.2-.1c-1.7-1.5-3.9-2.6-6.5-3.4-2.6-.8-5.5-1.2-8.5-1.2-4.3 0-8.1.9-11.3 2.6-3.2 1.7-5.7 4.2-7.5 7.3-1.8 3.1-2.6 6.9-2.6 11.1 0 4.4.9 8.2 2.6 11.3 1.8 3.1 4.3 5.6 7.6 7.3 3.3 1.7 7.2 2.5 11.6 2.5 2.9 0 5.7-.3 8.4-.8s5.2-1.3 7.2-2.2l.2-.1v-20.3h-17v6.8zm53.2-17.9v-7.6h-28.9v42.2h28.9v-7.6H371v-10h18.3v-7.7H371v-9.3z"/></svg>
|
After Width: | Height: | Size: 2.6 KiB |
69
client/index.html.tpl
Normal file
@ -0,0 +1,69 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, user-scalable=no">
|
||||
|
||||
<link rel="preload" as="script" href="js/loading-error-handlers.js?v=<%- cacheBust %>">
|
||||
<link rel="preload" as="script" href="js/bundle.vendor.js?v=<%- cacheBust %>">
|
||||
<link rel="preload" as="script" href="js/bundle.js?v=<%- cacheBust %>">
|
||||
|
||||
<link rel="stylesheet" href="css/style.css?v=<%- cacheBust %>">
|
||||
<link id="theme" rel="stylesheet" href="themes/<%- theme %>.css" data-server-theme="<%- theme %>">
|
||||
<% _.forEach(stylesheets, function(css) { %>
|
||||
<link rel="stylesheet" href="packages/<%- css %>">
|
||||
<% }); %>
|
||||
<style id="user-specified-css"></style>
|
||||
|
||||
<title>TripSit Web</title>
|
||||
|
||||
<!-- Browser tab icon -->
|
||||
<link id="favicon" rel="icon" sizes="16x16 32x32 64x64" href="favicon.ico" data-other="img/favicon-alerted.ico" type="image/x-icon">
|
||||
|
||||
<!-- Safari pinned tab icon -->
|
||||
<link rel="mask-icon" href="img/icon-black-transparent-bg.svg" color="#415364">
|
||||
|
||||
<link rel="manifest" href="thelounge.webmanifest">
|
||||
|
||||
<!-- iPhone 4, iPhone 4s, iPhone 5, iPhone 5c, iPhone 5s, iPhone 6, iPhone 6s, iPhone 7, iPhone 7s, iPhone8 -->
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="img/logo-grey-bg-120x120px.png">
|
||||
<!-- iPad and iPad mini @2x -->
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="img/logo-grey-bg-152x152px.png">
|
||||
<!-- iPad Pro -->
|
||||
<link rel="apple-touch-icon" sizes="167x167" href="img/logo-grey-bg-167x167px.png">
|
||||
<!-- iPhone X, iPhone 8 Plus, iPhone 7 Plus, iPhone 6s Plus, iPhone 6 Plus -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="img/logo-grey-bg-180x180px.png">
|
||||
|
||||
<!-- Windows 8/10 - Edge tiles -->
|
||||
<meta name="application-name" content="The Lounge">
|
||||
<meta name="msapplication-TileColor" content="<%- themeColor %>">
|
||||
<meta name="msapplication-square70x70logo" content="img/logo-grey-bg-120x120px.png">
|
||||
<meta name="msapplication-square150x150logo" content="img/logo-grey-bg-152x152px.png">
|
||||
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="theme-color" content="<%- themeColor %>">
|
||||
|
||||
</head>
|
||||
<body class="<%- public ? " public" : "" %>" data-transports="<%- JSON.stringify(transports) %>">
|
||||
<div id="viewport"></div>
|
||||
<div id="loading">
|
||||
<div class="window">
|
||||
<div id="loading-status-container">
|
||||
<img src="img/logo-vertical-transparent-bg.svg" class="logo" alt="The Lounge" width="256" height="170">
|
||||
<img src="img/logo-vertical-transparent-bg-inverted.svg" class="logo-inverted" alt="The Lounge" width="256" height="170">
|
||||
<p id="loading-page-message">Sorry! TripSit requires a modern browser with JavaScript enabled.</p>
|
||||
</div>
|
||||
<div id="loading-reload-container">
|
||||
<p id="loading-slow">This is taking longer than it should, there might be connectivity issues.</p>
|
||||
<button id="loading-reload" class="btn">Reload page</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="js/loading-error-handlers.js?v=<%- cacheBust %>"></script>
|
||||
<script src="js/bundle.vendor.js?v=<%- cacheBust %>"></script>
|
||||
<script src="js/bundle.js?v=<%- cacheBust %>"></script>
|
||||
</body>
|
||||
</html>
|
11
client/js/auth.js
Normal file
@ -0,0 +1,11 @@
|
||||
"use strict";
|
||||
|
||||
import storage from "./localStorage";
|
||||
import location from "./location";
|
||||
|
||||
export default class Auth {
|
||||
static signout() {
|
||||
storage.clear();
|
||||
location.reload();
|
||||
}
|
||||
}
|
331
client/js/autocompletion.js
Normal file
@ -0,0 +1,331 @@
|
||||
"use strict";
|
||||
|
||||
const constants = require("./constants");
|
||||
|
||||
import Mousetrap from "mousetrap";
|
||||
import {Textcomplete, Textarea} from "textcomplete";
|
||||
import fuzzy from "fuzzy";
|
||||
|
||||
import emojiMap from "./helpers/simplemap.json";
|
||||
import store from "./store";
|
||||
|
||||
export default enableAutocomplete;
|
||||
|
||||
const emojiSearchTerms = Object.keys(emojiMap);
|
||||
const emojiStrategy = {
|
||||
id: "emoji",
|
||||
match: /(^|\s):([-+\w:?]{2,}):?$/,
|
||||
search(term, callback) {
|
||||
// Trim colon from the matched term,
|
||||
// as we are unable to get a clean string from match regex
|
||||
term = term.replace(/:$/, "");
|
||||
callback(fuzzyGrep(term, emojiSearchTerms));
|
||||
},
|
||||
template([string, original]) {
|
||||
return `<span class="emoji">${emojiMap[original]}</span> ${string}`;
|
||||
},
|
||||
replace([, original]) {
|
||||
return "$1" + emojiMap[original];
|
||||
},
|
||||
index: 2,
|
||||
};
|
||||
|
||||
const nicksStrategy = {
|
||||
id: "nicks",
|
||||
match: /(^|\s)(@([a-zA-Z_[\]\\^{}|`@][a-zA-Z0-9_[\]\\^{}|`-]*)?)$/,
|
||||
search(term, callback) {
|
||||
term = term.slice(1);
|
||||
|
||||
if (term[0] === "@") {
|
||||
callback(completeNicks(term.slice(1), true).map((val) => ["@" + val[0], "@" + val[1]]));
|
||||
} else {
|
||||
callback(completeNicks(term, true));
|
||||
}
|
||||
},
|
||||
template([string]) {
|
||||
return string;
|
||||
},
|
||||
replace([, original]) {
|
||||
return "$1" + replaceNick(original);
|
||||
},
|
||||
index: 2,
|
||||
};
|
||||
|
||||
const chanStrategy = {
|
||||
id: "chans",
|
||||
match: /(^|\s)((?:#|\+|&|![A-Z0-9]{5})(?:[^\s]+)?)$/,
|
||||
search(term, callback) {
|
||||
callback(completeChans(term));
|
||||
},
|
||||
template([string]) {
|
||||
return string;
|
||||
},
|
||||
replace([, original]) {
|
||||
return "$1" + original;
|
||||
},
|
||||
index: 2,
|
||||
};
|
||||
|
||||
const commandStrategy = {
|
||||
id: "commands",
|
||||
match: /^\/(\w*)$/,
|
||||
search(term, callback) {
|
||||
callback(completeCommands("/" + term));
|
||||
},
|
||||
template([string]) {
|
||||
return string;
|
||||
},
|
||||
replace([, original]) {
|
||||
return original;
|
||||
},
|
||||
index: 1,
|
||||
};
|
||||
|
||||
const foregroundColorStrategy = {
|
||||
id: "foreground-colors",
|
||||
match: /\x03(\d{0,2}|[A-Za-z ]{0,10})$/,
|
||||
search(term, callback) {
|
||||
term = term.toLowerCase();
|
||||
|
||||
const matchingColorCodes = constants.colorCodeMap
|
||||
.filter((i) => fuzzy.test(term, i[0]) || fuzzy.test(term, i[1]))
|
||||
.map((i) => {
|
||||
if (fuzzy.test(term, i[1])) {
|
||||
return [
|
||||
i[0],
|
||||
fuzzy.match(term, i[1], {
|
||||
pre: "<b>",
|
||||
post: "</b>",
|
||||
}).rendered,
|
||||
];
|
||||
}
|
||||
|
||||
return i;
|
||||
});
|
||||
|
||||
callback(matchingColorCodes);
|
||||
},
|
||||
template(value) {
|
||||
return `<span class="irc-fg${parseInt(value[0], 10)}">${value[1]}</span>`;
|
||||
},
|
||||
replace(value) {
|
||||
return "\x03" + value[0];
|
||||
},
|
||||
index: 1,
|
||||
};
|
||||
|
||||
const backgroundColorStrategy = {
|
||||
id: "background-colors",
|
||||
match: /\x03(\d{2}),(\d{0,2}|[A-Za-z ]{0,10})$/,
|
||||
search(term, callback, match) {
|
||||
term = term.toLowerCase();
|
||||
const matchingColorCodes = constants.colorCodeMap
|
||||
.filter((i) => fuzzy.test(term, i[0]) || fuzzy.test(term, i[1]))
|
||||
.map((pair) => {
|
||||
if (fuzzy.test(term, pair[1])) {
|
||||
return [
|
||||
pair[0],
|
||||
fuzzy.match(term, pair[1], {
|
||||
pre: "<b>",
|
||||
post: "</b>",
|
||||
}).rendered,
|
||||
];
|
||||
}
|
||||
|
||||
return pair;
|
||||
})
|
||||
.map((pair) => pair.concat(match[1])); // Needed to pass fg color to `template`...
|
||||
|
||||
callback(matchingColorCodes);
|
||||
},
|
||||
template(value) {
|
||||
return `<span class="irc-fg${parseInt(value[2], 10)} irc-bg irc-bg${parseInt(
|
||||
value[0],
|
||||
10
|
||||
)}">${value[1]}</span>`;
|
||||
},
|
||||
replace(value) {
|
||||
return "\x03$1," + value[0];
|
||||
},
|
||||
index: 2,
|
||||
};
|
||||
|
||||
function enableAutocomplete(input) {
|
||||
let tabCount = 0;
|
||||
let lastMatch = "";
|
||||
let currentMatches = [];
|
||||
|
||||
input.addEventListener("input", (e) => {
|
||||
if (e.detail === "autocomplete") {
|
||||
return;
|
||||
}
|
||||
|
||||
tabCount = 0;
|
||||
currentMatches = [];
|
||||
lastMatch = "";
|
||||
});
|
||||
|
||||
Mousetrap(input).bind(
|
||||
"tab",
|
||||
(e) => {
|
||||
if (store.state.isAutoCompleting) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const text = input.value;
|
||||
|
||||
if (tabCount === 0) {
|
||||
lastMatch = text.substring(0, input.selectionStart).split(/\s/).pop();
|
||||
|
||||
if (lastMatch.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentMatches = completeNicks(lastMatch, false);
|
||||
|
||||
if (currentMatches.length === 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const position = input.selectionStart - lastMatch.length;
|
||||
const newMatch = replaceNick(
|
||||
currentMatches[tabCount % currentMatches.length],
|
||||
position
|
||||
);
|
||||
const remainder = text.substr(input.selectionStart);
|
||||
|
||||
input.value = text.substr(0, position) + newMatch + remainder;
|
||||
input.selectionStart -= remainder.length;
|
||||
input.selectionEnd = input.selectionStart;
|
||||
|
||||
// Propagate change to Vue model
|
||||
input.dispatchEvent(
|
||||
new CustomEvent("input", {
|
||||
detail: "autocomplete",
|
||||
})
|
||||
);
|
||||
|
||||
lastMatch = newMatch;
|
||||
tabCount++;
|
||||
},
|
||||
"keydown"
|
||||
);
|
||||
|
||||
const editor = new Textarea(input);
|
||||
const textcomplete = new Textcomplete(editor, {
|
||||
dropdown: {
|
||||
className: "textcomplete-menu",
|
||||
placement: "top",
|
||||
},
|
||||
});
|
||||
|
||||
textcomplete.register([
|
||||
emojiStrategy,
|
||||
nicksStrategy,
|
||||
chanStrategy,
|
||||
commandStrategy,
|
||||
foregroundColorStrategy,
|
||||
backgroundColorStrategy,
|
||||
]);
|
||||
|
||||
// Activate the first item by default
|
||||
// https://github.com/yuku-t/textcomplete/issues/93
|
||||
textcomplete.on("rendered", () => {
|
||||
if (textcomplete.dropdown.items.length > 0) {
|
||||
textcomplete.dropdown.items[0].activate();
|
||||
}
|
||||
});
|
||||
|
||||
textcomplete.on("show", () => {
|
||||
store.commit("isAutoCompleting", true);
|
||||
});
|
||||
|
||||
textcomplete.on("hidden", () => {
|
||||
store.commit("isAutoCompleting", false);
|
||||
});
|
||||
|
||||
return {
|
||||
hide() {
|
||||
textcomplete.hide();
|
||||
},
|
||||
destroy() {
|
||||
textcomplete.destroy();
|
||||
store.commit("isAutoCompleting", false);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function replaceNick(original, position = 1) {
|
||||
// If no postfix specified, return autocompleted nick as-is
|
||||
if (!store.state.settings.nickPostfix) {
|
||||
return original;
|
||||
}
|
||||
|
||||
// If there is whitespace in the input already, append space to nick
|
||||
if (position > 0 && /\s/.test(store.state.activeChannel.channel.pendingMessage)) {
|
||||
return original + " ";
|
||||
}
|
||||
|
||||
// If nick is first in the input, append specified postfix
|
||||
return original + store.state.settings.nickPostfix;
|
||||
}
|
||||
|
||||
function fuzzyGrep(term, array) {
|
||||
const results = fuzzy.filter(term, array, {
|
||||
pre: "<b>",
|
||||
post: "</b>",
|
||||
});
|
||||
return results.map((el) => [el.string, el.original]);
|
||||
}
|
||||
|
||||
function rawNicks() {
|
||||
if (store.state.activeChannel.channel.users.length > 0) {
|
||||
const users = store.state.activeChannel.channel.users.slice();
|
||||
|
||||
return users.sort((a, b) => b.lastMessage - a.lastMessage).map((u) => u.nick);
|
||||
}
|
||||
|
||||
const me = store.state.activeChannel.network.nick;
|
||||
const otherUser = store.state.activeChannel.channel.name;
|
||||
|
||||
// If this is a query, add their name to autocomplete
|
||||
if (me !== otherUser && store.state.activeChannel.channel.type === "query") {
|
||||
return [otherUser, me];
|
||||
}
|
||||
|
||||
// Return our own name by default for anything that isn't a channel or query
|
||||
return [me];
|
||||
}
|
||||
|
||||
function completeNicks(word, isFuzzy) {
|
||||
const users = rawNicks();
|
||||
word = word.toLowerCase();
|
||||
|
||||
if (isFuzzy) {
|
||||
return fuzzyGrep(word, users);
|
||||
}
|
||||
|
||||
return users.filter((w) => !w.toLowerCase().indexOf(word));
|
||||
}
|
||||
|
||||
function completeCommands(word) {
|
||||
const words = constants.commands.slice();
|
||||
|
||||
return fuzzyGrep(word, words);
|
||||
}
|
||||
|
||||
function completeChans(word) {
|
||||
const words = [];
|
||||
|
||||
for (const channel of store.state.activeChannel.network.channels) {
|
||||
// Push all channels that start with the same CHANTYPE
|
||||
if (channel.type === "channel" && channel.name[0] === word[0]) {
|
||||
words.push(channel.name);
|
||||
}
|
||||
}
|
||||
|
||||
return fuzzyGrep(word, words);
|
||||
}
|
31
client/js/clipboard.js
Normal file
@ -0,0 +1,31 @@
|
||||
"use strict";
|
||||
|
||||
export default function (chat) {
|
||||
// Disable in Firefox as it already copies flex text correctly
|
||||
if (typeof window.InstallTrigger !== "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
|
||||
// If selection does not span multiple elements, do nothing
|
||||
if (selection.anchorNode === selection.focusNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const documentFragment = range.cloneContents();
|
||||
const div = document.createElement("div");
|
||||
|
||||
div.id = "js-copy-hack";
|
||||
div.appendChild(documentFragment);
|
||||
chat.appendChild(div);
|
||||
|
||||
selection.selectAllChildren(div);
|
||||
|
||||
window.setTimeout(() => {
|
||||
chat.removeChild(div);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}, 0);
|
||||
}
|
36
client/js/commands/collapse.js
Normal file
@ -0,0 +1,36 @@
|
||||
"use strict";
|
||||
|
||||
import socket from "../socket";
|
||||
import store from "../store";
|
||||
|
||||
function input() {
|
||||
const messageIds = [];
|
||||
|
||||
for (const message of store.state.activeChannel.channel.messages) {
|
||||
let toggled = false;
|
||||
|
||||
for (const preview of message.previews) {
|
||||
if (preview.shown) {
|
||||
preview.shown = false;
|
||||
toggled = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (toggled) {
|
||||
messageIds.push(message.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Tell the server we're toggling so it remembers at page reload
|
||||
if (messageIds.length > 0) {
|
||||
socket.emit("msg:preview:toggle", {
|
||||
target: store.state.activeChannel.channel.id,
|
||||
messageIds: messageIds,
|
||||
shown: false,
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export default {input};
|
36
client/js/commands/expand.js
Normal file
@ -0,0 +1,36 @@
|
||||
"use strict";
|
||||
|
||||
import socket from "../socket";
|
||||
import store from "../store";
|
||||
|
||||
function input() {
|
||||
const messageIds = [];
|
||||
|
||||
for (const message of store.state.activeChannel.channel.messages) {
|
||||
let toggled = false;
|
||||
|
||||
for (const preview of message.previews) {
|
||||
if (!preview.shown) {
|
||||
preview.shown = true;
|
||||
toggled = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (toggled) {
|
||||
messageIds.push(message.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Tell the server we're toggling so it remembers at page reload
|
||||
if (messageIds.length > 0) {
|
||||
socket.emit("msg:preview:toggle", {
|
||||
target: store.state.activeChannel.channel.id,
|
||||
messageIds: messageIds,
|
||||
shown: true,
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export default {input};
|
21
client/js/commands/index.js
Normal file
@ -0,0 +1,21 @@
|
||||
"use strict";
|
||||
|
||||
// Taken from views/index.js
|
||||
|
||||
// This creates a version of `require()` in the context of the current
|
||||
// directory, so we iterate over its content, which is a map statically built by
|
||||
// Webpack.
|
||||
// Second argument says it's recursive, third makes sure we only load javascript.
|
||||
const commands = require.context("./", true, /\.js$/);
|
||||
|
||||
export default commands.keys().reduce((acc, path) => {
|
||||
const command = path.substring(2, path.length - 3);
|
||||
|
||||
if (command === "index") {
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc[command] = commands(path).default;
|
||||
|
||||
return acc;
|
||||
}, {});
|
49
client/js/commands/join.js
Normal file
@ -0,0 +1,49 @@
|
||||
"use strict";
|
||||
|
||||
import socket from "../socket";
|
||||
import store from "../store";
|
||||
import {switchToChannel} from "../router";
|
||||
|
||||
function input(args) {
|
||||
if (args.length > 0) {
|
||||
let channels = args[0];
|
||||
|
||||
if (channels.length > 0) {
|
||||
const chanTypes = store.state.activeChannel.network.serverOptions.CHANTYPES;
|
||||
const channelList = args[0].split(",");
|
||||
|
||||
if (chanTypes && chanTypes.length > 0) {
|
||||
for (let c = 0; c < channelList.length; c++) {
|
||||
if (!chanTypes.includes(channelList[c][0])) {
|
||||
channelList[c] = chanTypes[0] + channelList[c];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
channels = channelList.join(",");
|
||||
|
||||
const chan = store.getters.findChannelOnCurrentNetwork(channels);
|
||||
|
||||
if (chan) {
|
||||
switchToChannel(chan);
|
||||
} else {
|
||||
socket.emit("input", {
|
||||
text: `/join ${channels} ${args.length > 1 ? args[1] : ""}`,
|
||||
target: store.state.activeChannel.channel.id,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else if (store.state.activeChannel.channel.type === "channel") {
|
||||
// If `/join` command is used without any arguments, re-join current channel
|
||||
socket.emit("input", {
|
||||
target: store.state.activeChannel.channel.id,
|
||||
text: `/join ${store.state.activeChannel.channel.name}`,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export default {input};
|
39
client/js/constants.js
Normal file
@ -0,0 +1,39 @@
|
||||
"use strict";
|
||||
|
||||
const colorCodeMap = [
|
||||
["00", "White"],
|
||||
["01", "Black"],
|
||||
["02", "Blue"],
|
||||
["03", "Green"],
|
||||
["04", "Red"],
|
||||
["05", "Brown"],
|
||||
["06", "Magenta"],
|
||||
["07", "Orange"],
|
||||
["08", "Yellow"],
|
||||
["09", "Light Green"],
|
||||
["10", "Cyan"],
|
||||
["11", "Light Cyan"],
|
||||
["12", "Light Blue"],
|
||||
["13", "Pink"],
|
||||
["14", "Grey"],
|
||||
["15", "Light Grey"],
|
||||
];
|
||||
|
||||
const condensedTypes = new Set(["chghost", "join", "part", "quit", "nick", "kick", "mode"]);
|
||||
|
||||
const timeFormats = {
|
||||
msgDefault: "HH:mm",
|
||||
msgWithSeconds: "HH:mm:ss",
|
||||
msg12h: "hh:mm A",
|
||||
msg12hWithSeconds: "hh:mm:ss A",
|
||||
};
|
||||
|
||||
// This file is required by server, can't use es6 export
|
||||
module.exports = {
|
||||
colorCodeMap,
|
||||
commands: [],
|
||||
condensedTypes,
|
||||
timeFormats,
|
||||
// Same value as media query in CSS that forces sidebars to become overlays
|
||||
mobileViewportPixels: 768,
|
||||
};
|
51
client/js/eventbus.js
Normal file
@ -0,0 +1,51 @@
|
||||
const events = new Map();
|
||||
|
||||
class EventBus {
|
||||
/**
|
||||
* Register an event handler for the given type.
|
||||
*
|
||||
* @param {String} type Type of event to listen for.
|
||||
* @param {Function} handler Function to call in response to given event.
|
||||
*/
|
||||
on(type, handler) {
|
||||
if (events.has(type)) {
|
||||
events.get(type).push(handler);
|
||||
} else {
|
||||
events.set(type, [handler]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an event handler for the given type.
|
||||
*
|
||||
* @param {String} type Type of event to unregister `handler` from.
|
||||
* @param {Function} handler Handler function to remove.
|
||||
*/
|
||||
off(type, handler) {
|
||||
if (events.has(type)) {
|
||||
events.set(
|
||||
type,
|
||||
events.get(type).filter((item) => item !== handler)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke all handlers for the given type.
|
||||
*
|
||||
* @param {String} type The event type to invoke.
|
||||
* @param {Any} [evt] Any value (object is recommended and powerful), passed to each handler.
|
||||
*/
|
||||
emit(type, ...evt) {
|
||||
if (events.has(type)) {
|
||||
events
|
||||
.get(type)
|
||||
.slice()
|
||||
.map((handler) => {
|
||||
handler(...evt);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new EventBus();
|
16
client/js/helpers/collapseNetwork.js
Normal file
@ -0,0 +1,16 @@
|
||||
"use strict";
|
||||
|
||||
import storage from "../localStorage";
|
||||
|
||||
export default (network, isCollapsed) => {
|
||||
const networks = new Set(JSON.parse(storage.get("thelounge.networks.collapsed")));
|
||||
network.isCollapsed = isCollapsed;
|
||||
|
||||
if (isCollapsed) {
|
||||
networks.add(network.uuid);
|
||||
} else {
|
||||
networks.delete(network.uuid);
|
||||
}
|
||||
|
||||
storage.set("thelounge.networks.collapsed", JSON.stringify([...networks]));
|
||||
};
|
17
client/js/helpers/colorClass.js
Normal file
@ -0,0 +1,17 @@
|
||||
"use strict";
|
||||
|
||||
// Generates a string from "color-1" to "color-32" based on an input string
|
||||
export default (str) => {
|
||||
let hash = 0;
|
||||
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash += str.charCodeAt(i);
|
||||
}
|
||||
|
||||
/*
|
||||
Modulo 32 lets us be case insensitive for ascii
|
||||
due to A being ascii 65 (100 0001)
|
||||
while a being ascii 97 (110 0001)
|
||||
*/
|
||||
return "color-" + (1 + (hash % 32));
|
||||
};
|