Initial comission of TheLounge base files

This commit is contained in:
cranberry 2020-11-01 22:46:04 +00:00
parent 4a1086b047
commit ed23347e56
27941 changed files with 3855890 additions and 0 deletions

3841
CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

22
LICENSE Normal file
View 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
View 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

Binary file not shown.

129
client/components/App.vue Normal file
View 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>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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" />&#32;<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">&lt;</span>
<Username :user="message.from" />
<span class="only-copy">&gt; </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>

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

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

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

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

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

View File

@ -0,0 +1,23 @@
<template>
<span class="content">
<Username :user="message.from" />&#32;
<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>

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

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

View 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;
}, {});

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

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

View 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"
>&#32;(<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>

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

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

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

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

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

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

View File

@ -0,0 +1,13 @@
<template>
<span class="content">{{ message.text }}</span>
</template>
<script>
export default {
name: "MessageTypeRaw",
props: {
network: Object,
message: Object,
},
};
</script>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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>015</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>015</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>

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

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

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

File diff suppressed because it is too large Load Diff

BIN
client/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
client/img/amsDfxn.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 661 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 960 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

View 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

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 420 KiB

View 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

View 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

View 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

View 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

View 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
View 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
View 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
View 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
View 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);
}

View 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};

View 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};

View 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;
}, {});

View 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
View 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
View 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();

View 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]));
};

View 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));
};

Some files were not shown because too many files have changed in this diff Show More