Merge pull request #3524 from thelounge/vue

Complete porting The Lounge client to the Vue.js framework
This commit is contained in:
Pavel Djundik 2019-11-26 13:51:45 +02:00 committed by GitHub
commit e74c35687e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
155 changed files with 5296 additions and 4456 deletions

View File

@ -1,5 +1,2 @@
# third party
client/js/libs/jquery/*.js
public/ public/
coverage/ coverage/

View File

@ -9,8 +9,8 @@
# Ignore client folder as it's being built into public/ folder # Ignore client folder as it's being built into public/ folder
# except for the specified files which are used by the server # except for the specified files which are used by the server
client/** client/**
!client/js/libs/handlebars/ircmessageparser/findLinks.js !client/js/helpers/ircmessageparser/findLinks.js
!client/js/libs/handlebars/ircmessageparser/cleanIrcMessage.js !client/js/helpers/ircmessageparser/cleanIrcMessage.js
!client/index.html.tpl !client/index.html.tpl
public/js/bundle.vendor.js.map public/js/bundle.vendor.js.map

View File

@ -1,95 +1,44 @@
<template> <template>
<div id="viewport" role="tablist"> <div id="viewport" :class="viewportClasses" role="tablist">
<aside id="sidebar"> <Sidebar v-if="$store.state.appLoaded" :overlay="$refs.overlay" />
<div class="scrollable-area"> <div id="sidebar-overlay" ref="overlay" @click="$store.commit('sidebarOpen', false)" />
<div class="logo-container">
<img
:src="`img/logo-${isPublic() ? 'horizontal-' : ''}transparent-bg.svg`"
class="logo"
alt="The Lounge"
/>
<img
:src="
`img/logo-${isPublic() ? 'horizontal-' : ''}transparent-bg-inverted.svg`
"
class="logo-inverted"
alt="The Lounge"
/>
</div>
<NetworkList :networks="networks" :active-channel="activeChannel" />
</div>
<footer id="footer">
<span class="tooltipped tooltipped-n tooltipped-no-touch" aria-label="Sign in"
><button
class="icon sign-in"
data-target="#sign-in"
aria-label="Sign in"
role="tab"
aria-controls="sign-in"
aria-selected="false"
/></span>
<span
class="tooltipped tooltipped-n tooltipped-no-touch"
aria-label="Connect to network"
><button
class="icon connect"
data-target="#connect"
aria-label="Connect to network"
role="tab"
aria-controls="connect"
aria-selected="false"
/></span>
<span class="tooltipped tooltipped-n tooltipped-no-touch" aria-label="Settings"
><button
class="icon settings"
data-target="#settings"
aria-label="Settings"
role="tab"
aria-controls="settings"
aria-selected="false"
/></span>
<span class="tooltipped tooltipped-n tooltipped-no-touch" aria-label="Help"
><button
class="icon help"
data-target="#help"
aria-label="Help"
role="tab"
aria-controls="help"
aria-selected="false"
/></span>
</footer>
</aside>
<div id="sidebar-overlay" />
<article id="windows"> <article id="windows">
<Chat <router-view ref="window"></router-view>
v-if="activeChannel"
:network="activeChannel.network"
:channel="activeChannel.channel"
/>
<div id="sign-in" class="window" role="tabpanel" aria-label="Sign-in" />
<div id="connect" class="window" role="tabpanel" aria-label="Connect" />
<div id="settings" class="window" role="tabpanel" aria-label="Settings" />
<div id="help" class="window" role="tabpanel" aria-label="Help" />
<div id="changelog" class="window" aria-label="Changelog" />
</article> </article>
<ImageViewer ref="imageViewer" />
<ContextMenu ref="contextMenu" />
<div id="upload-overlay"></div>
</div> </div>
</template> </template>
<script> <script>
const throttle = require("lodash/throttle"); import throttle from "lodash/throttle";
import constants from "../js/constants";
import storage from "../js/localStorage";
import NetworkList from "./NetworkList.vue"; import Sidebar from "./Sidebar.vue";
import Chat from "./Chat.vue"; import ImageViewer from "./ImageViewer.vue";
import ContextMenu from "./ContextMenu.vue";
export default { export default {
name: "App", name: "App",
components: { components: {
NetworkList, Sidebar,
Chat, ImageViewer,
ContextMenu,
}, },
props: { computed: {
activeChannel: Object, viewportClasses() {
networks: Array, 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() { mounted() {
// Make a single throttled resize listener available to all components // Make a single throttled resize listener available to all components
@ -113,7 +62,6 @@ export default {
clearTimeout(this.dayChangeTimeout); clearTimeout(this.dayChangeTimeout);
}, },
methods: { methods: {
isPublic: () => document.body.classList.contains("public"),
msUntilNextDay() { msUntilNextDay() {
// Compute how many milliseconds are remaining until the next day starts // Compute how many milliseconds are remaining until the next day starts
const today = new Date(); const today = new Date();
@ -121,6 +69,25 @@ export default {
return tommorow - today; return tommorow - today;
}, },
prepareOpenStates() {
const viewportWidth = window.outerWidth;
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> </script>

View File

@ -1,8 +1,8 @@
<template> <template>
<ChannelWrapper :network="network" :channel="channel" :active-channel="activeChannel"> <ChannelWrapper ref="wrapper" :network="network" :channel="channel">
<span class="name">{{ channel.name }}</span> <span class="name">{{ channel.name }}</span>
<span v-if="channel.unread" :class="{highlight: channel.highlight}" class="badge">{{ <span v-if="channel.unread" :class="{highlight: channel.highlight}" class="badge">{{
channel.unread | roundBadgeNumber unreadCount
}}</span> }}</span>
<template v-if="channel.type === 'channel'"> <template v-if="channel.type === 'channel'">
<span <span
@ -13,18 +13,19 @@
<span class="parted-channel-icon" /> <span class="parted-channel-icon" />
</span> </span>
<span class="close-tooltip tooltipped tooltipped-w" aria-label="Leave"> <span class="close-tooltip tooltipped tooltipped-w" aria-label="Leave">
<button class="close" aria-label="Leave" /> <button class="close" aria-label="Leave" @click.stop="close" />
</span> </span>
</template> </template>
<template v-else> <template v-else>
<span class="close-tooltip tooltipped tooltipped-w" aria-label="Close"> <span class="close-tooltip tooltipped tooltipped-w" aria-label="Close">
<button class="close" aria-label="Close" /> <button class="close" aria-label="Close" @click.stop="close" />
</span> </span>
</template> </template>
</ChannelWrapper> </ChannelWrapper>
</template> </template>
<script> <script>
import roundBadgeNumber from "../js/helpers/roundBadgeNumber";
import ChannelWrapper from "./ChannelWrapper.vue"; import ChannelWrapper from "./ChannelWrapper.vue";
export default { export default {
@ -33,9 +34,18 @@ export default {
ChannelWrapper, ChannelWrapper,
}, },
props: { props: {
activeChannel: Object,
network: Object, network: Object,
channel: Object, channel: Object,
}, },
computed: {
unreadCount() {
return roundBadgeNumber(this.channel.unread);
},
},
methods: {
close() {
this.$root.closeChannel(this.channel);
},
},
}; };
</script> </script>

View File

@ -1,11 +1,8 @@
<template> <template>
<!-- TODO: move closed style to it's own class -->
<div <div
v-if=" v-if="isChannelVisible"
!network.isCollapsed || ref="element"
channel.highlight ||
channel.type === 'lobby' ||
(activeChannel && channel === activeChannel.channel)
"
:class="[ :class="[
'chan', 'chan',
channel.type, channel.type,
@ -14,24 +11,34 @@
]" ]"
:aria-label="getAriaLabel()" :aria-label="getAriaLabel()"
:title="getAriaLabel()" :title="getAriaLabel()"
:data-id="channel.id"
:data-target="'#chan-' + channel.id"
:data-name="channel.name" :data-name="channel.name"
:aria-controls="'#chan-' + channel.id" :aria-controls="'#chan-' + channel.id"
:aria-selected="activeChannel && channel === activeChannel.channel" :aria-selected="activeChannel && channel === activeChannel.channel"
:style="channel.closed ? {transition: 'none', opacity: 0.4} : null"
role="tab" role="tab"
@click="click"
@contextmenu.prevent="openContextMenu"
> >
<slot :network="network" :channel="channel" :activeChannel="activeChannel" /> <slot :network="network" :channel="channel" :activeChannel="activeChannel" />
</div> </div>
</template> </template>
<script> <script>
import isChannelCollapsed from "../js/helpers/isChannelCollapsed";
export default { export default {
name: "ChannelWrapper", name: "ChannelWrapper",
props: { props: {
network: Object, network: Object,
channel: Object, channel: Object,
activeChannel: Object, },
computed: {
activeChannel() {
return this.$store.state.activeChannel;
},
isChannelVisible() {
return !isChannelCollapsed(this.network, this.channel);
},
}, },
methods: { methods: {
getAriaLabel() { getAriaLabel() {
@ -51,6 +58,16 @@ export default {
return this.channel.name; return this.channel.name;
}, },
click() {
this.$root.switchToChannel(this.channel);
},
openContextMenu(event) {
this.$root.$emit("contextmenu:channel", {
event: event,
channel: this.channel,
network: this.network,
});
},
}, },
}; };
</script> </script>

View File

@ -2,23 +2,20 @@
<div id="chat-container" class="window" :data-current-channel="channel.name"> <div id="chat-container" class="window" :data-current-channel="channel.name">
<div <div
id="chat" id="chat"
:data-id="channel.id"
:class="{ :class="{
'hide-motd': !this.$root.settings.motd, 'hide-motd': !$store.state.settings.motd,
'colored-nicks': this.$root.settings.coloredNicks, 'colored-nicks': $store.state.settings.coloredNicks,
'show-seconds': this.$root.settings.showSeconds, 'show-seconds': $store.state.settings.showSeconds,
}" }"
> >
<div <div
:id="'chan-' + channel.id" :id="'chan-' + channel.id"
:class="[channel.type, 'chan', 'active']" :class="[channel.type, 'chan', 'active']"
:data-id="channel.id"
:data-type="channel.type"
:aria-label="channel.name" :aria-label="channel.name"
role="tabpanel" role="tabpanel"
> >
<div class="header"> <div class="header">
<button class="lt" aria-label="Toggle channel list" /> <SidebarToggle />
<span class="title">{{ channel.name }}</span> <span class="title">{{ channel.name }}</span>
<div v-if="channel.editTopic === true" class="topic-container"> <div v-if="channel.editTopic === true" class="topic-container">
<input <input
@ -38,13 +35,21 @@
:network="network" :network="network"
:text="channel.topic" :text="channel.topic"
/></span> /></span>
<button class="menu" aria-label="Open the context menu" /> <button
class="menu"
aria-label="Open the context menu"
@click="openContextMenu"
/>
<span <span
v-if="channel.type === 'channel'" v-if="channel.type === 'channel'"
class="rt-tooltip tooltipped tooltipped-w" class="rt-tooltip tooltipped tooltipped-w"
aria-label="Toggle user list" aria-label="Toggle user list"
> >
<button class="rt" aria-label="Toggle user list" /> <button
class="rt"
aria-label="Toggle user list"
@click="$store.commit('toggleUserlist')"
/>
</span> </span>
</div> </div>
<div v-if="channel.type === 'special'" class="chat-content"> <div v-if="channel.type === 'special'" class="chat-content">
@ -77,11 +82,11 @@
</div> </div>
</div> </div>
<div <div
v-if="this.$root.currentUserVisibleError" v-if="this.$store.state.currentUserVisibleError"
id="user-visible-error" id="user-visible-error"
@click="hideUserVisibleError" @click="hideUserVisibleError"
> >
{{ this.$root.currentUserVisibleError }} {{ this.$store.state.currentUserVisibleError }}
</div> </div>
<span id="upload-progressbar" /> <span id="upload-progressbar" />
<ChatInput :network="network" :channel="channel" /> <ChatInput :network="network" :channel="channel" />
@ -89,11 +94,12 @@
</template> </template>
<script> <script>
const socket = require("../js/socket"); import socket from "../js/socket";
import ParsedMessage from "./ParsedMessage.vue"; import ParsedMessage from "./ParsedMessage.vue";
import MessageList from "./MessageList.vue"; import MessageList from "./MessageList.vue";
import ChatInput from "./ChatInput.vue"; import ChatInput from "./ChatInput.vue";
import ChatUserList from "./ChatUserList.vue"; import ChatUserList from "./ChatUserList.vue";
import SidebarToggle from "./SidebarToggle.vue";
import ListBans from "./Special/ListBans.vue"; import ListBans from "./Special/ListBans.vue";
import ListInvites from "./Special/ListInvites.vue"; import ListInvites from "./Special/ListInvites.vue";
import ListChannels from "./Special/ListChannels.vue"; import ListChannels from "./Special/ListChannels.vue";
@ -106,6 +112,7 @@ export default {
MessageList, MessageList,
ChatInput, ChatInput,
ChatUserList, ChatUserList,
SidebarToggle,
}, },
props: { props: {
network: Object, network: Object,
@ -127,9 +134,32 @@ export default {
return undefined; return undefined;
}, },
}, },
watch: {
channel() {
this.channelChanged();
},
},
mounted() {
this.channelChanged();
},
methods: { 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() { hideUserVisibleError() {
this.$root.currentUserVisibleError = null; this.$store.commit("currentUserVisibleError", null);
}, },
editTopic() { editTopic() {
if (this.channel.type === "channel") { if (this.channel.type === "channel") {
@ -150,6 +180,13 @@ export default {
socket.emit("input", {target, text}); socket.emit("input", {target, text});
} }
}, },
openContextMenu(event) {
this.$root.$emit("contextmenu:channel", {
event: event,
channel: this.channel,
network: this.network,
});
},
}, },
}; };
</script> </script>

View File

@ -8,12 +8,11 @@
:value="channel.pendingMessage" :value="channel.pendingMessage"
:placeholder="getInputPlaceholder(channel)" :placeholder="getInputPlaceholder(channel)"
:aria-label="getInputPlaceholder(channel)" :aria-label="getInputPlaceholder(channel)"
class="mousetrap"
@input="setPendingMessage" @input="setPendingMessage"
@keypress.enter.exact.prevent="onSubmit" @keypress.enter.exact.prevent="onSubmit"
/> />
<span <span
v-if="this.$root.isFileUploadEnabled" v-if="$store.state.serverConfiguration.fileUpload"
id="upload-tooltip" id="upload-tooltip"
class="tooltipped tooltipped-w tooltipped-no-touch" class="tooltipped tooltipped-w tooltipped-no-touch"
aria-label="Upload file" aria-label="Upload file"
@ -30,7 +29,7 @@
id="upload" id="upload"
type="button" type="button"
aria-label="Upload file" aria-label="Upload file"
:disabled="!this.$root.isConnected" :disabled="!$store.state.isConnected"
/> />
</span> </span>
<span <span
@ -42,18 +41,19 @@
id="submit" id="submit"
type="submit" type="submit"
aria-label="Send message" aria-label="Send message"
:disabled="!this.$root.isConnected" :disabled="!$store.state.isConnected"
/> />
</span> </span>
</form> </form>
</template> </template>
<script> <script>
const commands = require("../js/commands/index"); import Mousetrap from "mousetrap";
const socket = require("../js/socket"); import {wrapCursor} from "undate";
const upload = require("../js/upload"); import autocompletion from "../js/autocompletion";
const Mousetrap = require("mousetrap"); import commands from "../js/commands/index";
const {wrapCursor} = require("undate"); import socket from "../js/socket";
import upload from "../js/upload";
const formattingHotkeys = { const formattingHotkeys = {
"mod+k": "\x03", "mod+k": "\x03",
@ -80,6 +80,8 @@ const bracketWraps = {
_: "_", _: "_",
}; };
let autocompletionRef = null;
export default { export default {
name: "ChatInput", name: "ChatInput",
props: { props: {
@ -87,13 +89,18 @@ export default {
channel: Object, channel: Object,
}, },
watch: { watch: {
"channel.id"() {
if (autocompletionRef) {
autocompletionRef.hide();
}
},
"channel.pendingMessage"() { "channel.pendingMessage"() {
this.setInputSize(); this.setInputSize();
}, },
}, },
mounted() { mounted() {
if (this.$root.settings.autocomplete) { if (this.$store.state.settings.autocomplete) {
require("../js/autocompletion").enable(this.$refs.input); autocompletionRef = autocompletion(this.$refs.input);
} }
const inputTrap = Mousetrap(this.$refs.input); const inputTrap = Mousetrap(this.$refs.input);
@ -119,7 +126,10 @@ export default {
}); });
inputTrap.bind(["up", "down"], (e, key) => { inputTrap.bind(["up", "down"], (e, key) => {
if (this.$root.isAutoCompleting || e.target.selectionStart !== e.target.selectionEnd) { if (
this.$store.state.isAutoCompleting ||
e.target.selectionStart !== e.target.selectionEnd
) {
return; return;
} }
@ -144,12 +154,16 @@ export default {
return false; return false;
}); });
if (this.$root.isFileUploadEnabled) { if (this.$store.state.serverConfiguration.fileUpload) {
upload.mounted(); upload.mounted();
} }
}, },
destroyed() { destroyed() {
require("../js/autocompletion").disable(); if (autocompletionRef) {
autocompletionRef.destroy();
autocompletionRef = null;
}
upload.abort(); upload.abort();
}, },
methods: { methods: {
@ -187,7 +201,7 @@ export default {
this.$refs.input.click(); this.$refs.input.click();
this.$refs.input.focus(); this.$refs.input.focus();
if (!this.$root.isConnected) { if (!this.$store.state.isConnected) {
return false; return false;
} }
@ -198,6 +212,10 @@ export default {
return false; return false;
} }
if (autocompletionRef) {
autocompletionRef.hide();
}
this.channel.inputHistoryPosition = 0; this.channel.inputHistoryPosition = 0;
this.channel.pendingMessage = ""; this.channel.pendingMessage = "";
this.$refs.input.value = ""; this.$refs.input.value = "";

View File

@ -26,12 +26,13 @@
:class="['user-mode', getModeClass(mode)]" :class="['user-mode', getModeClass(mode)]"
> >
<template v-if="userSearchInput.length > 0"> <template v-if="userSearchInput.length > 0">
<UsernameFiltered <Username
v-for="user in users" v-for="user in users"
:key="user.original.nick" :key="user.original.nick"
:on-hover="hoverUser" :on-hover="hoverUser"
:active="user.original === activeUser" :active="user.original === activeUser"
:user="user" :user="user.original"
v-html="user.original.mode + user.string"
/> />
</template> </template>
<template v-else> <template v-else>
@ -49,9 +50,8 @@
</template> </template>
<script> <script>
const fuzzy = require("fuzzy"); import {filter as fuzzyFilter} from "fuzzy";
import Username from "./Username.vue"; import Username from "./Username.vue";
import UsernameFiltered from "./UsernameFiltered.vue";
const modes = { const modes = {
"~": "owner", "~": "owner",
@ -67,7 +67,6 @@ export default {
name: "ChatUserList", name: "ChatUserList",
components: { components: {
Username, Username,
UsernameFiltered,
}, },
props: { props: {
channel: Object, channel: Object,
@ -82,7 +81,7 @@ export default {
// filteredUsers is computed, to avoid unnecessary filtering // filteredUsers is computed, to avoid unnecessary filtering
// as it is shared between filtering and keybindings. // as it is shared between filtering and keybindings.
filteredUsers() { filteredUsers() {
return fuzzy.filter(this.userSearchInput, this.channel.users, { return fuzzyFilter(this.userSearchInput, this.channel.users, {
pre: "<b>", pre: "<b>",
post: "</b>", post: "</b>",
extract: (u) => u.nick, extract: (u) => u.nick,
@ -149,6 +148,7 @@ export default {
// Prevent propagation to stop global keybind handler from capturing pagedown/pageup // Prevent propagation to stop global keybind handler from capturing pagedown/pageup
// and redirecting it to the message list container for scrolling // and redirecting it to the message list container for scrolling
event.stopImmediatePropagation(); event.stopImmediatePropagation();
event.preventDefault();
let users = this.channel.users; let users = this.channel.users;

View File

@ -0,0 +1,200 @@
<template>
<div
v-if="isOpen"
id="context-menu-container"
@click="containerClick"
@contextmenu.prevent="containerClick"
>
<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"
@mouseover="hoverItem(id)"
@click="clickItem(item)"
>
{{ item.label }}
</li>
</template>
</ul>
</div>
</template>
<script>
import Mousetrap from "mousetrap";
import {
generateUserContextMenu,
generateChannelContextMenu,
generateRemoveNetwork,
} from "../js/helpers/contextMenu.js";
export default {
name: "ContextMenu",
props: {
message: Object,
},
data() {
return {
isOpen: false,
previousActiveElement: null,
items: [],
activeItem: -1,
style: {
left: 0,
top: 0,
},
};
},
mounted() {
Mousetrap.bind("esc", this.close);
Mousetrap.bind(["up", "down", "tab", "shift+tab"], this.navigateMenu);
this.$root.$on("contextmenu:user", this.openUserContextMenu);
this.$root.$on("contextmenu:channel", this.openChannelContextMenu);
this.$root.$on("contextmenu:removenetwork", this.openRemoveNetworkContextMenu);
},
destroyed() {
Mousetrap.unbind("esc", this.close);
Mousetrap.unbind(["up", "down", "tab", "shift+tab"], this.navigateMenu);
this.$root.$off("contextmenu:user", this.openUserContextMenu);
this.$root.$off("contextmenu:channel", this.openChannelContextMenu);
this.$root.$off("contextmenu:removenetwork", this.openRemoveNetworkContextMenu);
},
methods: {
openRemoveNetworkContextMenu(data) {
const items = generateRemoveNetwork(this.$root, data.lobby);
this.open(data.event, items);
},
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}
);
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(event, key) {
event.preventDefault();
const direction = key === "down" || key === "tab" ? 1 : -1;
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

@ -1,13 +1,16 @@
<template> <template>
<div :aria-label="localeDate" class="date-marker-container tooltipped tooltipped-s"> <div :aria-label="localeDate" class="date-marker-container tooltipped tooltipped-s">
<div class="date-marker"> <div class="date-marker">
<span :data-label="friendlyDate()" class="date-marker-text" /> <span :aria-label="friendlyDate()" class="date-marker-text" />
</div> </div>
</div> </div>
</template> </template>
<script> <script>
const moment = require("moment"); import dayjs from "dayjs";
import calendar from "dayjs/plugin/calendar";
dayjs.extend(calendar);
export default { export default {
name: "DateMarker", name: "DateMarker",
@ -16,7 +19,7 @@ export default {
}, },
computed: { computed: {
localeDate() { localeDate() {
return moment(this.message.time).format("D MMMM YYYY"); return dayjs(this.message.time).format("D MMMM YYYY");
}, },
}, },
mounted() { mounted() {
@ -40,7 +43,7 @@ export default {
}, },
friendlyDate() { friendlyDate() {
// See http://momentjs.com/docs/#/displaying/calendar-time/ // See http://momentjs.com/docs/#/displaying/calendar-time/
return moment(this.message.time).calendar(null, { return dayjs(this.message.time).calendar(null, {
sameDay: "[Today]", sameDay: "[Today]",
lastDay: "[Yesterday]", lastDay: "[Yesterday]",
lastWeek: "D MMMM YYYY", lastWeek: "D MMMM YYYY",

View File

@ -0,0 +1,354 @@
<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>
<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";
export default {
name: "ImageViewer",
data() {
return {
link: null,
position: {
x: 0,
y: 0,
},
transform: {
x: 0,
y: 0,
scale: 0,
},
};
},
computed: {
computeImageStyles() {
return {
left: `${this.position.x}px`,
top: `${this.position.y}px`,
transform: `translate3d(${this.transform.x}px, ${this.transform.y}px, 0) scale3d(${this.transform.scale}, ${this.transform.scale}, 1)`,
};
},
},
watch: {
link() {
// TODO: history.pushState
if (this.link === null) {
return;
}
this.$root.$on("resize", this.correctPosition);
},
},
mounted() {
Mousetrap.bind("esc", this.closeViewer);
},
destroyed() {
Mousetrap.unbind("esc", this.closeViewer);
},
methods: {
closeViewer() {
if (this.link === null) {
return;
}
this.$root.$off("resize", this.correctPosition);
this.link = null;
},
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 = -image.naturalWidth / 2;
this.position.y = -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);
// TODO: There's bugs with multi finger interactions, needs more testing
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
);
if (newScale > 1) {
this.transform.x = fixedPosition.x + deltaX;
this.transform.y = fixedPosition.y + deltaY;
} else if (Math.abs(deltaX) > Math.abs(deltaY)) {
this.transform.x = fixedPosition.x + deltaX;
} else {
this.transform.y = fixedPosition.y + deltaY;
}
this.transform.scale = newScale;
};
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;
}
};
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

@ -59,14 +59,12 @@ export default {
}, },
methods: { methods: {
onSubmit() { onSubmit() {
const channelToFind = this.inputChannel.toLowerCase(); const existingChannel = this.$store.getters.findChannelOnCurrentNetwork(
const existingChannel = this.network.channels.find( this.inputChannel
(c) => c.name.toLowerCase() === channelToFind
); );
if (existingChannel) { if (existingChannel) {
const $ = require("jquery"); this.$root.switchToChannel(existingChannel);
$(`#sidebar .chan[data-id="${existingChannel.id}"]`).trigger("click");
} else { } else {
const chanTypes = this.network.serverOptions.CHANTYPES; const chanTypes = this.network.serverOptions.CHANTYPES;
let channel = this.inputChannel; let channel = this.inputChannel;

View File

@ -1,5 +1,11 @@
<template> <template>
<div v-if="link.shown" v-show="link.canDisplay" ref="container" class="preview" dir="ltr"> <div
v-if="link.shown"
v-show="link.sourceLoaded || link.type === 'link'"
ref="container"
class="preview"
dir="ltr"
>
<div <div
ref="content" ref="content"
:class="['toggle-content', 'toggle-type-' + link.type, {opened: isContentShown}]" :class="['toggle-content', 'toggle-type-' + link.type, {opened: isContentShown}]"
@ -7,10 +13,12 @@
<template v-if="link.type === 'link'"> <template v-if="link.type === 'link'">
<a <a
v-if="link.thumb" v-if="link.thumb"
v-show="link.sourceLoaded"
:href="link.link" :href="link.link"
class="toggle-thumbnail" class="toggle-thumbnail"
target="_blank" target="_blank"
rel="noopener" rel="noopener"
@click="onThumbnailClick"
> >
<img <img
:src="link.thumb" :src="link.thumb"
@ -54,24 +62,45 @@
</div> </div>
</template> </template>
<template v-else-if="link.type === 'image'"> <template v-else-if="link.type === 'image'">
<a :href="link.link" class="toggle-thumbnail" target="_blank" rel="noopener"> <a
<img :src="link.thumb" decoding="async" alt="" @load="onPreviewReady" /> :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> </a>
</template> </template>
<template v-else-if="link.type === 'video'"> <template v-else-if="link.type === 'video'">
<video preload="metadata" controls @canplay="onPreviewReady"> <video
v-show="link.sourceLoaded"
preload="metadata"
controls
@canplay="onPreviewReady"
>
<source :src="link.media" :type="link.mediaType" /> <source :src="link.media" :type="link.mediaType" />
</video> </video>
</template> </template>
<template v-else-if="link.type === 'audio'"> <template v-else-if="link.type === 'audio'">
<audio controls preload="metadata" @canplay="onPreviewReady"> <audio
v-show="link.sourceLoaded"
controls
preload="metadata"
@canplay="onPreviewReady"
>
<source :src="link.media" :type="link.mediaType" /> <source :src="link.media" :type="link.mediaType" />
</audio> </audio>
</template> </template>
<template v-else-if="link.type === 'error'"> <template v-else-if="link.type === 'error'">
<em v-if="link.error === 'image-too-big'"> <em v-if="link.error === 'image-too-big'">
This image is larger than {{ link.maxSize | friendlysize }} and cannot be This image is larger than {{ imageMaxSize }} and cannot be previewed.
previewed.
<a :href="link.link" target="_blank" rel="noopener">Click here</a> <a :href="link.link" target="_blank" rel="noopener">Click here</a>
to open it in a new window. to open it in a new window.
</em> </em>
@ -101,6 +130,8 @@
</template> </template>
<script> <script>
import friendlysize from "../js/helpers/friendlysize";
export default { export default {
name: "LinkPreview", name: "LinkPreview",
props: { props: {
@ -117,6 +148,13 @@ export default {
moreButtonLabel() { moreButtonLabel() {
return this.isContentShown ? "Less" : "More"; return this.isContentShown ? "Less" : "More";
}, },
imageMaxSize() {
if (!this.link.maxSize) {
return;
}
return friendlysize(this.link.maxSize);
},
}, },
watch: { watch: {
"link.type"() { "link.type"() {
@ -138,7 +176,7 @@ export default {
destroyed() { destroyed() {
// Let this preview go through load/canplay events again, // Let this preview go through load/canplay events again,
// Otherwise the browser can cause a resize on video elements // Otherwise the browser can cause a resize on video elements
this.link.canDisplay = false; this.link.sourceLoaded = false;
}, },
methods: { methods: {
onPreviewUpdate() { onPreviewUpdate() {
@ -147,32 +185,37 @@ export default {
return; return;
} }
// Error don't have any media to render // Error does not have any media to render
if (this.link.type === "error") { if (this.link.type === "error") {
this.onPreviewReady(); this.onPreviewReady();
} }
// If link doesn't have a thumbnail, render it // If link doesn't have a thumbnail, render it
if (this.link.type === "link" && !this.link.thumb) { if (this.link.type === "link") {
this.onPreviewReady(); this.handleResize();
this.keepScrollPosition();
} }
}, },
onPreviewReady() { onPreviewReady() {
this.$set(this.link, "canDisplay", true); this.$set(this.link, "sourceLoaded", true);
this.keepScrollPosition(); this.keepScrollPosition();
if (this.link.type !== "link") { if (this.link.type === "link") {
return; this.handleResize();
} }
this.handleResize();
}, },
onThumbnailError() { onThumbnailError() {
// If thumbnail fails to load, hide it and show the preview without it // If thumbnail fails to load, hide it and show the preview without it
this.link.thumb = ""; this.link.thumb = "";
this.onPreviewReady(); this.onPreviewReady();
}, },
onThumbnailClick(e) {
e.preventDefault();
const imageViewer = this.$root.$refs.app.$refs.imageViewer;
imageViewer.link = this.link;
},
onMoreClick() { onMoreClick() {
this.isContentShown = !this.isContentShown; this.isContentShown = !this.isContentShown;
this.keepScrollPosition(); this.keepScrollPosition();
@ -194,8 +237,8 @@ export default {
case "error": case "error":
defaultState = defaultState =
this.link.error === "image-too-big" this.link.error === "image-too-big"
? this.$root.settings.media ? this.$store.state.settings.media
: this.$root.settings.links; : this.$store.state.settings.links;
break; break;
case "loading": case "loading":
@ -203,11 +246,11 @@ export default {
break; break;
case "link": case "link":
defaultState = this.$root.settings.links; defaultState = this.$store.state.settings.links;
break; break;
default: default:
defaultState = this.$root.settings.media; defaultState = this.$store.state.settings.media;
} }
this.link.shown = this.link.shown && defaultState; this.link.shown = this.link.shown && defaultState;

View File

@ -3,7 +3,7 @@
</template> </template>
<script> <script>
const constants = require("../js/constants"); import constants from "../js/constants";
export default { export default {
name: "LinkPreviewFileSize", name: "LinkPreviewFileSize",

View File

@ -21,7 +21,6 @@
<span class="from"><span class="only-copy">* </span></span> <span class="from"><span class="only-copy">* </span></span>
<span class="content" dir="auto"> <span class="content" dir="auto">
<Username :user="message.from" dir="auto" />&#32;<ParsedMessage <Username :user="message.from" dir="auto" />&#32;<ParsedMessage
:network="network"
:message="message" :message="message"
/> />
<LinkPreview <LinkPreview
@ -68,13 +67,12 @@
</template> </template>
<script> <script>
import dayjs from "dayjs";
import Username from "./Username.vue"; import Username from "./Username.vue";
import LinkPreview from "./LinkPreview.vue"; import LinkPreview from "./LinkPreview.vue";
import ParsedMessage from "./ParsedMessage.vue"; import ParsedMessage from "./ParsedMessage.vue";
import MessageTypes from "./MessageTypes"; import MessageTypes from "./MessageTypes";
import constants from "../js/constants";
const moment = require("moment");
const constants = require("../js/constants");
MessageTypes.ParsedMessage = ParsedMessage; MessageTypes.ParsedMessage = ParsedMessage;
MessageTypes.LinkPreview = LinkPreview; MessageTypes.LinkPreview = LinkPreview;
@ -85,24 +83,22 @@ export default {
components: MessageTypes, components: MessageTypes,
props: { props: {
message: Object, message: Object,
channel: Object,
network: Object, network: Object,
keepScrollPosition: Function, keepScrollPosition: Function,
}, },
computed: { computed: {
messageTime() { messageTime() {
const format = this.$root.settings.showSeconds const format = this.$store.state.settings.showSeconds
? constants.timeFormats.msgWithSeconds ? constants.timeFormats.msgWithSeconds
: constants.timeFormats.msgDefault; : constants.timeFormats.msgDefault;
return moment(this.message.time).format(format); return dayjs(this.message.time).format(format);
}, },
messageComponent() { messageComponent() {
return "message-" + this.message.type; return "message-" + this.message.type;
}, },
}, },
mounted() {
require("../js/renderPreview");
},
methods: { methods: {
isAction() { isAction() {
return typeof MessageTypes["message-" + this.message.type] !== "undefined"; return typeof MessageTypes["message-" + this.message.type] !== "undefined";

View File

@ -18,7 +18,7 @@
</template> </template>
<script> <script>
const constants = require("../js/constants"); import constants from "../js/constants";
import Message from "./Message.vue"; import Message from "./Message.vue";
export default { export default {

View File

@ -3,7 +3,7 @@
<div :class="['show-more', {show: channel.moreHistoryAvailable}]"> <div :class="['show-more', {show: channel.moreHistoryAvailable}]">
<button <button
ref="loadMoreButton" ref="loadMoreButton"
:disabled="channel.historyLoading || !$root.isConnected" :disabled="channel.historyLoading || !$store.state.isConnected"
class="btn" class="btn"
@click="onShowMoreClick" @click="onShowMoreClick"
> >
@ -42,6 +42,7 @@
<Message <Message
v-else v-else
:key="message.id" :key="message.id"
:channel="channel"
:network="network" :network="network"
:message="message" :message="message"
:keep-scroll-position="keepScrollPosition" :keep-scroll-position="keepScrollPosition"
@ -55,8 +56,8 @@
<script> <script>
require("intersection-observer"); require("intersection-observer");
const constants = require("../js/constants"); import constants from "../js/constants";
const clipboard = require("../js/clipboard"); import clipboard from "../js/clipboard";
import socket from "../js/socket"; import socket from "../js/socket";
import Message from "./Message.vue"; import Message from "./Message.vue";
import MessageCondensed from "./MessageCondensed.vue"; import MessageCondensed from "./MessageCondensed.vue";
@ -80,14 +81,14 @@ export default {
} }
// If actions are hidden, just return a message list with them excluded // If actions are hidden, just return a message list with them excluded
if (this.$root.settings.statusMessages === "hidden") { if (this.$store.state.settings.statusMessages === "hidden") {
return this.channel.messages.filter( return this.channel.messages.filter(
(message) => !constants.condensedTypes.includes(message.type) (message) => !constants.condensedTypes.includes(message.type)
); );
} }
// If actions are not condensed, just return raw message list // If actions are not condensed, just return raw message list
if (this.$root.settings.statusMessages !== "condensed") { if (this.$store.state.settings.statusMessages !== "condensed") {
return this.channel.messages; return this.channel.messages;
} }
@ -218,8 +219,6 @@ export default {
this.keepScrollPosition(); this.keepScrollPosition();
// Tell the server we're toggling so it remembers at page reload // Tell the server we're toggling so it remembers at page reload
// TODO Avoid sending many single events when using `/collapse` or `/expand`
// See https://github.com/thelounge/thelounge/issues/1377
socket.emit("msg:preview:toggle", { socket.emit("msg:preview:toggle", {
target: this.channel.id, target: this.channel.id,
msgId: message.id, msgId: message.id,
@ -228,7 +227,7 @@ export default {
}); });
}, },
onShowMoreClick() { onShowMoreClick() {
if (!this.$root.isConnected) { if (!this.$store.state.isConnected) {
return; return;
} }

View File

@ -6,7 +6,7 @@
// Second argument says it's recursive, third makes sure we only load templates. // Second argument says it's recursive, third makes sure we only load templates.
const requireViews = require.context(".", false, /\.vue$/); const requireViews = require.context(".", false, /\.vue$/);
module.exports = requireViews.keys().reduce((acc, path) => { export default requireViews.keys().reduce((acc, path) => {
acc["message-" + path.substring(2, path.length - 4)] = requireViews(path).default; acc["message-" + path.substring(2, path.length - 4)] = requireViews(path).default;
return acc; return acc;

View File

@ -0,0 +1,242 @@
<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">
<div class="row">
<div class="col-sm-12">
<h1 class="title">
<template v-if="defaults.uuid">
<input type="hidden" name="uuid" :value="defaults.uuid" />
Edit {{ defaults.name }}
</template>
<template v-else>
<template v-if="config.public">The Lounge - </template>
Connect
<template v-if="!config.displayNetwork">
<template v-if="config.lockNetwork">
to {{ defaults.name }}
</template>
</template>
</template>
</h1>
</div>
<template v-if="config.displayNetwork">
<div>
<div class="col-sm-12">
<h2>Network settings</h2>
</div>
<div class="col-sm-3">
<label for="connect:name">Name</label>
</div>
<div class="col-sm-9">
<input
id="connect:name"
class="input"
name="name"
:value="defaults.name"
maxlength="100"
/>
</div>
<div class="col-sm-3">
<label for="connect:host">Server</label>
</div>
<div class="col-sm-6 col-xs-8">
<input
id="connect:host"
class="input"
name="host"
:value="defaults.host"
aria-label="Server address"
maxlength="255"
required
:disabled="config.lockNetwork ? true : false"
/>
</div>
<div class="col-sm-3 col-xs-4">
<div class="port">
<input
class="input"
type="number"
min="1"
max="65535"
name="port"
:value="defaults.port"
aria-label="Server port"
:disabled="config.lockNetwork ? true : false"
/>
</div>
</div>
<div class="clearfix" />
<div class="col-sm-9 col-sm-offset-3">
<label class="tls">
<input
type="checkbox"
name="tls"
:checked="defaults.tls ? true : false"
:disabled="config.lockNetwork ? true : false"
/>
Use secure connection (TLS)
</label>
</div>
<div class="col-sm-9 col-sm-offset-3">
<label class="tls">
<input
type="checkbox"
name="rejectUnauthorized"
:checked="defaults.rejectUnauthorized ? true : false"
:disabled="config.lockNetwork ? true : false"
/>
Only allow trusted certificates
</label>
</div>
<div class="clearfix" />
</div>
</template>
<div class="col-sm-12">
<h2>User preferences</h2>
</div>
<div class="col-sm-3">
<label for="connect:nick">Nick</label>
</div>
<div class="col-sm-9">
<input
id="connect:nick"
class="input nick"
name="nick"
:value="defaults.nick"
maxlength="100"
required
@input="onNickChanged"
/>
</div>
<template v-if="!config.useHexIp">
<div class="col-sm-3">
<label for="connect:username">Username</label>
</div>
<div class="col-sm-9">
<input
id="connect:username"
ref="usernameInput"
class="input username"
name="username"
:value="defaults.username"
maxlength="512"
/>
</div>
</template>
<div class="col-sm-3">
<label for="connect:password">Password</label>
</div>
<div class="col-sm-9 password-container">
<RevealPassword v-slot:default="slotProps">
<input
id="connect:password"
v-model="defaults.password"
class="input"
:type="slotProps.isVisible ? 'text' : 'password'"
name="password"
maxlength="512"
/>
</RevealPassword>
</div>
<div class="col-sm-3">
<label for="connect:realname">Real name</label>
</div>
<div class="col-sm-9">
<input
id="connect:realname"
class="input"
name="realname"
:value="defaults.realname"
maxlength="512"
/>
</div>
<template v-if="defaults.uuid">
<div class="col-sm-3">
<label for="connect:commands">Commands</label>
</div>
<div class="col-sm-9">
<textarea
id="connect:commands"
class="input"
name="commands"
placeholder="One raw command per line, each command will be executed on new connection"
:value="defaults.commands ? defaults.commands.join('\n') : ''"
/>
</div>
<div class="col-sm-9 col-sm-offset-3">
<button type="submit" class="btn" :disabled="disabled ? true : false">
Save
</button>
</div>
</template>
<template v-else>
<div class="col-sm-3">
<label for="connect:channels">Channels</label>
</div>
<div class="col-sm-9">
<input
id="connect:channels"
class="input"
name="join"
:value="defaults.join"
/>
</div>
<div class="col-sm-9 col-sm-offset-3">
<button type="submit" class="btn" :disabled="disabled ? true : false">
Connect
</button>
</div>
</template>
</div>
</form>
</div>
</template>
<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,
};
},
methods: {
onNickChanged(event) {
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);
},
},
};
</script>

View File

@ -26,14 +26,11 @@
'not-connected': !network.status.connected, 'not-connected': !network.status.connected,
'not-secure': !network.status.secure, 'not-secure': !network.status.secure,
}" }"
:data-uuid="network.uuid"
:data-nick="network.nick"
class="network" class="network"
role="region" role="region"
> >
<NetworkLobby <NetworkLobby
:network="network" :network="network"
:active-channel="activeChannel"
:is-join-channel-shown="network.isJoinChannelShown" :is-join-channel-shown="network.isJoinChannelShown"
@toggleJoinChannel="network.isJoinChannelShown = !network.isJoinChannelShown" @toggleJoinChannel="network.isJoinChannelShown = !network.isJoinChannelShown"
/> />
@ -63,7 +60,6 @@
:key="channel.id" :key="channel.id"
:channel="channel" :channel="channel"
:network="network" :network="network"
:active-channel="activeChannel"
/> />
</Draggable> </Draggable>
</div> </div>
@ -86,9 +82,10 @@ export default {
Channel, Channel,
Draggable, Draggable,
}, },
props: { computed: {
activeChannel: Object, networks() {
networks: Array, return this.$store.state.networks;
},
}, },
methods: { methods: {
isCurrentlyInTouch(e) { isCurrentlyInTouch(e) {
@ -116,8 +113,7 @@ export default {
return; return;
} }
const {findChannel} = require("../js/vue"); const channel = this.$store.getters.findChannel(e.moved.element.id);
const channel = findChannel(e.moved.element.id);
if (!channel) { if (!channel) {
return; return;

View File

@ -1,5 +1,5 @@
<template> <template>
<ChannelWrapper :network="network" :channel="channel" :active-channel="activeChannel"> <ChannelWrapper :network="network" :channel="channel">
<button <button
v-if="network.channels.length > 1" v-if="network.channels.length > 1"
:aria-controls="'network-' + network.uuid" :aria-controls="'network-' + network.uuid"
@ -28,7 +28,7 @@
<span class="not-connected-icon" /> <span class="not-connected-icon" />
</span> </span>
<span v-if="channel.unread" :class="{highlight: channel.highlight}" class="badge">{{ <span v-if="channel.unread" :class="{highlight: channel.highlight}" class="badge">{{
channel.unread | roundBadgeNumber unreadCount
}}</span> }}</span>
</div> </div>
<span <span
@ -46,8 +46,9 @@
</template> </template>
<script> <script>
import roundBadgeNumber from "../js/helpers/roundBadgeNumber";
import ChannelWrapper from "./ChannelWrapper.vue"; import ChannelWrapper from "./ChannelWrapper.vue";
const storage = require("../js/localStorage"); import storage from "../js/localStorage";
export default { export default {
name: "Channel", name: "Channel",
@ -55,7 +56,6 @@ export default {
ChannelWrapper, ChannelWrapper,
}, },
props: { props: {
activeChannel: Object,
network: Object, network: Object,
isJoinChannelShown: Boolean, isJoinChannelShown: Boolean,
}, },
@ -66,6 +66,9 @@ export default {
joinChannelLabel() { joinChannelLabel() {
return this.isJoinChannelShown ? "Cancel" : "Join a channel…"; return this.isJoinChannelShown ? "Cancel" : "Join a channel…";
}, },
unreadCount() {
return roundBadgeNumber(this.channel.unread);
},
}, },
methods: { methods: {
onCollapseClick() { onCollapseClick() {

View File

@ -1,5 +1,5 @@
<script> <script>
const parse = require("../js/libs/handlebars/parse"); import parse from "../js/helpers/parse";
export default { export default {
name: "ParsedMessage", name: "ParsedMessage",

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,50 @@
<template>
<p>
<button class="btn pull-right remove-session" @click.prevent="signOut">
<template v-if="session.current">
Sign out
</template>
<template v-else>
Revoke
</template>
</button>
<strong>{{ session.agent }}</strong>
<a :href="'https://ipinfo.io/' + session.ip" target="_blank" rel="noopener">{{
session.ip
}}</a>
<template v-if="!session.current">
<br />
<template v-if="session.active">
<em>Currently active</em>
</template>
<template v-else>
Last used on <time>{{ session.lastUse | localetime }}</time>
</template>
</template>
</p>
</template>
<script>
import Auth from "../js/auth";
import socket from "../js/socket";
export default {
name: "Session",
props: {
session: Object,
},
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,190 @@
<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="The Lounge"
/>
<img
:src="`img/logo-${isPublic() ? 'horizontal-' : ''}transparent-bg-inverted.svg`"
class="logo-inverted"
alt="The Lounge"
/>
<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 network"
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="Help"
><router-link
to="/help"
tag="button"
active-class="active"
:class="['icon', 'help']"
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

@ -1,14 +1,18 @@
<template> <template>
<span <span
:class="['user', $options.filters.colorClass(user.nick), {active: active}]" :class="['user', nickColor, {active: active}]"
:data-name="user.nick" :data-name="user.nick"
role="button" role="button"
v-on="onHover ? {mouseover: hover} : {}" v-on="onHover ? {mouseover: hover} : {}"
>{{ user.mode }}{{ user.nick }}</span @click.prevent="openContextMenu"
@contextmenu.prevent="openContextMenu"
><slot>{{ user.mode }}{{ user.nick }}</slot></span
> >
</template> </template>
<script> <script>
import colorClass from "../js/helpers/colorClass";
export default { export default {
name: "Username", name: "Username",
props: { props: {
@ -16,10 +20,21 @@ export default {
active: Boolean, active: Boolean,
onHover: Function, onHover: Function,
}, },
computed: {
nickColor() {
return colorClass(this.user.nick);
},
},
methods: { methods: {
hover() { hover() {
return this.onHover(this.user); return this.onHover(this.user);
}, },
openContextMenu(event) {
this.$root.$emit("contextmenu:user", {
event: event,
user: this.user,
});
},
}, },
}; };
</script> </script>

View File

@ -1,25 +0,0 @@
<template>
<span
:class="['user', $options.filters.colorClass(user.original.nick), {active: active}]"
:data-name="user.original.nick"
role="button"
@mouseover="hover"
v-html="user.original.mode + user.string"
/>
</template>
<script>
export default {
name: "UsernameFiltered",
props: {
user: Object,
active: Boolean,
onHover: Function,
},
methods: {
hover() {
this.onHover ? this.onHover(this.user.original) : null;
},
},
};
</script>

View File

@ -0,0 +1,69 @@
<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 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 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 releases 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",
data() {
return {
status: "loading",
};
},
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,87 @@
<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 releases 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,112 @@
<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)) {
if (!params[key]) {
continue;
}
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 &&
["host", "port", "tls", "rejectUnauthorized"].includes(key)
) {
continue;
}
// When the network is not displayed, its name in the UI is not customizable
if (!this.$store.state.serverConfiguration.displayNetwork && key === "name") {
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,703 @@
<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>
<small class="pull-right">
v{{ $store.state.serverConfiguration.version }} (<router-link
id="view-changelog"
to="/changelog"
>release notes</router-link
>)
</small>
About The Lounge
</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></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>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,590 @@
<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 class="row">
<div class="col-sm-6">
<label class="opt">
<input
:checked="$store.state.settings.advanced"
type="checkbox"
name="advanced"
/>
Advanced settings
</label>
</div>
</div>
<div class="row">
<div v-if="canRegisterProtocol || hasInstallPromptEvent" class="col-sm-12">
<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
"
class="col-sm-12"
>
<h2>
Settings synchronisation
<span
class="tooltipped tooltipped-n tooltipped-no-delay"
aria-label="Note: This is an experimental feature and may change in future releases."
>
<button
class="extra-experimental"
aria-label="Note: This is an experimental feature and may change in future releases."
/>
</span>
</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>
<div class="col-sm-12">
<h2>Messages</h2>
</div>
<div class="col-sm-6">
<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 class="col-sm-6">
<label class="opt">
<input
:checked="$store.state.settings.showSeconds"
type="checkbox"
name="showSeconds"
/>
Show seconds in timestamp
</label>
</div>
<div class="col-sm-12">
<h2>
Status messages
<span
class="tooltipped tooltipped-n tooltipped-no-delay"
aria-label="Joins, parts, kicks, nick changes, away changes, and mode changes"
>
<button
class="extra-help"
aria-label="Joins, parts, kicks, nick changes, away changes, and mode changes"
/>
</span>
</h2>
</div>
<div class="col-sm-12">
<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>
<div class="col-sm-12">
<h2>Visual Aids</h2>
</div>
<div class="col-sm-12">
<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" class="col-sm-12">
<label class="opt">
<label for="nickPostfix" class="sr-only">
Nick autocomplete postfix (e.g. <code>, </code>)
</label>
<input
id="nickPostfix"
:value="$store.state.settings.nickPostfix"
type="text"
name="nickPostfix"
class="input"
placeholder="Nick autocomplete postfix (e.g. ', ')"
/>
</label>
</div>
<div class="col-sm-12">
<h2>Theme</h2>
</div>
<div class="col-sm-12">
<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.settings.prefetch">
<div class="col-sm-12">
<h2>Link previews</h2>
</div>
<div class="col-sm-6">
<label class="opt">
<input
:checked="$store.state.settings.media"
type="checkbox"
name="media"
/>
Auto-expand media
</label>
</div>
<div class="col-sm-6">
<label class="opt">
<input
:checked="$store.state.settings.links"
type="checkbox"
name="links"
/>
Auto-expand websites
</label>
</div>
</template>
<template v-if="!$store.state.serverConfiguration.public">
<div class="col-sm-12">
<h2>Push Notifications</h2>
</div>
<div class="col-sm-12">
<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>
</div>
</template>
<div class="col-sm-12">
<h2>Browser Notifications</h2>
</div>
<div class="col-sm-12">
<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 class="col-sm-12">
<label class="opt">
<input
:checked="$store.state.settings.notification"
type="checkbox"
name="notification"
/>
Enable notification sound
</label>
</div>
<div class="col-sm-12">
<div class="opt">
<button id="play" @click.prevent="playNotification">Play sound</button>
</div>
</div>
<div v-if="$store.state.settings.advanced" class="col-sm-12">
<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.settings.advanced" class="col-sm-12">
<label class="opt">
<label for="highlights" class="sr-only">
Custom highlights (comma-separated keywords)
</label>
<input
id="highlights"
:value="$store.state.settings.highlights"
type="text"
name="highlights"
class="input"
placeholder="Custom highlights (comma-separated keywords)"
/>
</label>
</div>
<div
v-if="
!$store.state.serverConfiguration.public &&
!$store.state.serverConfiguration.ldapEnabled
"
id="change-password"
>
<div class="col-sm-12">
<h2>Change password</h2>
</div>
<div class="col-sm-12 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="col-sm-12 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="col-sm-12 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="col-sm-12 feedback success"
>
Successfully updated your password
</div>
<div
v-else-if="passwordChangeStatus && passwordChangeStatus.error"
class="col-sm-12 feedback error"
>
{{ passwordErrors[passwordChangeStatus.error] }}
</div>
<div class="col-sm-12">
<button type="submit" class="btn" @click.prevent="changePassword">
Change password
</button>
</div>
</div>
<div v-if="$store.state.settings.advanced" class="col-sm-12">
<h2>Custom Stylesheet</h2>
</div>
<div v-if="$store.state.settings.advanced" class="col-sm-12">
<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>
<div v-if="!$store.state.serverConfiguration.public" class="session-list">
<h2>Sessions</h2>
<h3>Current session</h3>
<div v-if="$store.getters.currentSession" id="session-current">
<Session :session="$store.getters.currentSession" />
</div>
<h3>Other sessions</h3>
<div id="session-list">
<p v-if="$store.state.sessions.length == 0">Loading</p>
<p v-else-if="$store.getters.otherSessions.length == 0">
<em>You are not currently logged in to any other device.</em>
</p>
<template v-else>
<Session
v-for="session in $store.getters.otherSessions"
:key="session.token"
:session="session"
/>
</template>
</div>
</div>
</form>
</div>
</template>
<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",
},
};
},
computed: {
hasInstallPromptEvent() {
// TODO: This doesn't hide the button after clicking
return installPromptEvent !== null;
},
},
mounted() {
socket.emit("sessions:get");
// Enable protocol handler registration if supported
if (window.navigator.registerProtocolHandler) {
this.canRegisterProtocol = true;
}
},
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>

View File

@ -278,6 +278,7 @@ kbd {
#help .report-issue-link::before, #help .report-issue-link::before,
#image-viewer .previous-image-btn::before, #image-viewer .previous-image-btn::before,
#image-viewer .next-image-btn::before, #image-viewer .next-image-btn::before,
#image-viewer .open-btn::before,
#sidebar .not-secure-icon::before, #sidebar .not-secure-icon::before,
#sidebar .not-connected-icon::before, #sidebar .not-connected-icon::before,
#sidebar .parted-channel-icon::before, #sidebar .parted-channel-icon::before,
@ -323,7 +324,6 @@ kbd {
#sidebar .chan.special::before { content: "\f03a"; /* http://fontawesome.io/icon/list/ */ } #sidebar .chan.special::before { content: "\f03a"; /* http://fontawesome.io/icon/list/ */ }
#footer .sign-in::before { content: "\f023"; /* http://fontawesome.io/icon/lock/ */ }
#footer .connect::before { content: "\f067"; /* http://fontawesome.io/icon/plus/ */ } #footer .connect::before { content: "\f067"; /* http://fontawesome.io/icon/plus/ */ }
#footer .settings::before { content: "\f013"; /* http://fontawesome.io/icon/cog/ */ } #footer .settings::before { content: "\f013"; /* http://fontawesome.io/icon/cog/ */ }
#footer .help::before { content: "\f059"; /* http://fontawesome.io/icon/question/ */ } #footer .help::before { content: "\f059"; /* http://fontawesome.io/icon/question/ */ }
@ -477,6 +477,10 @@ kbd {
content: "\f105"; /* http://fontawesome.io/icon/angle-right/ */ content: "\f105"; /* http://fontawesome.io/icon/angle-right/ */
} }
#image-viewer .open-btn::before {
content: "\f35d"; /* https://fontawesome.com/icons/external-link-alt?style=solid */
}
/* End icons */ /* End icons */
#viewport { #viewport {
@ -559,6 +563,7 @@ kbd {
display: none; display: none;
flex-direction: column; flex-direction: column;
width: 220px; width: 220px;
max-height: 100%;
will-change: transform; will-change: transform;
} }
@ -848,26 +853,6 @@ background on hover (unless active) */
border-radius: 5px; border-radius: 5px;
} }
.signed-out #footer .sign-in {
display: inline-block;
}
.signed-out #footer .connect {
display: none;
}
.public #footer .sign-in {
display: none;
}
#footer .sign-in {
display: none;
}
.signed-out #sidebar {
display: none; /* Hide the sidebar when user is signed out */
}
#windows li, #windows li,
#windows p, #windows p,
#windows label, #windows label,
@ -908,7 +893,8 @@ background on hover (unless active) */
.window { .window {
background: var(--window-bg-color); background: var(--window-bg-color);
display: none; display: flex;
flex-direction: column;
overflow-y: auto; overflow-y: auto;
height: 100%; height: 100%;
scrollbar-width: thin; scrollbar-width: thin;
@ -949,11 +935,6 @@ background on hover (unless active) */
margin: 20px 0 10px; margin: 20px 0 10px;
} }
#windows .window.active {
display: flex;
flex-direction: column;
}
#windows .header { #windows .header {
line-height: 45px; line-height: 45px;
height: 45px; height: 45px;
@ -1253,7 +1234,7 @@ background on hover (unless active) */
} }
#chat .date-marker-text::before { #chat .date-marker-text::before {
content: attr(data-label); content: attr(aria-label);
background-color: var(--window-bg-color); background-color: var(--window-bg-color);
color: var(--date-marker-color); color: var(--date-marker-color);
padding: 0 10px; padding: 0 10px;
@ -1486,6 +1467,10 @@ background on hover (unless active) */
transform: rotate(90deg); transform: rotate(90deg);
} }
#chat .preview {
display: flex; /* Fix odd margin added by inline-flex in .toggle-content */
}
#chat .toggle-content { #chat .toggle-content {
background: #f6f6f6; background: #f6f6f6;
border-radius: 5px; border-radius: 5px;
@ -1749,7 +1734,6 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
#sign-in .error { #sign-in .error {
color: #e74c3c; color: #e74c3c;
display: none; /* This message gets displayed on error only */
margin-top: 1em; margin-top: 1em;
width: 100%; width: 100%;
} }
@ -1780,8 +1764,33 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
margin-top: 30px; margin-top: 30px;
} }
#settings .sync-warning-base { #settings .settings-sync-panel {
display: none; padding: 10px;
margin-bottom: 16px;
border-radius: 2px;
background-color: #d9edf7;
color: #31708f;
}
#settings .settings-sync-panel p:last-child {
margin-bottom: 0;
}
#settings .settings-sync-panel .btn {
color: #007bff;
border-color: #007bff;
margin-bottom: 0;
}
#settings .settings-sync-panel .btn:hover,
#settings .settings-sync-panel .btn:focus {
background-color: #007bff;
color: #fff;
}
#settings .settings-sync-panel .btn:active,
#settings .settings-sync-panel .btn:focus {
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.5);
} }
#settings .opt { #settings .opt {
@ -1878,7 +1887,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
content: "\f00c"; /* https://fontawesome.com/icons/check?style=solid */ content: "\f00c"; /* https://fontawesome.com/icons/check?style=solid */
} }
.password-container .reveal-password.visible span::before { .password-container .reveal-password-visible span::before {
content: "\f070"; /* https://fontawesome.com/icons/eye-slash?style=solid */ content: "\f070"; /* https://fontawesome.com/icons/eye-slash?style=solid */
color: #ff4136; color: #ff4136;
} }
@ -1903,15 +1912,6 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
margin-bottom: 0; margin-bottom: 0;
} }
.is-apple #help .key-all,
#help .key-apple {
display: none;
}
.is-apple #help .key-apple {
display: inline-block;
}
.whois { .whois {
display: grid; display: grid;
grid-template-columns: max-content auto; grid-template-columns: max-content auto;
@ -2003,11 +2003,6 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
color: #3c763d; color: #3c763d;
} }
#version-checker.up-to-date #check-now {
/* "Check now" button is hidden until data expires */
display: none;
}
#version-checker.up-to-date::before { #version-checker.up-to-date::before {
content: "\f00c"; /* http://fontawesome.io/icon/check/ */ content: "\f00c"; /* http://fontawesome.io/icon/check/ */
} }
@ -2101,7 +2096,6 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
} }
#context-menu-container { #context-menu-container {
display: none;
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
@ -2123,6 +2117,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.15); box-shadow: 0 3px 12px rgba(0, 0, 0, 0.15);
border: 1px solid rgba(0, 0, 0, 0.15); border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 5px; border-radius: 5px;
outline: 0;
} }
.context-menu-divider { .context-menu-divider {
@ -2142,17 +2137,16 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
line-height: 1.4; line-height: 1.4;
transition: background-color 0.2s; transition: background-color 0.2s;
border-radius: 3px; border-radius: 3px;
white-space: nowrap;
} }
.context-menu-item:focus, .context-menu-item.active,
.textcomplete-item:focus, .textcomplete-item:focus,
.context-menu-item:hover,
.textcomplete-item:hover, .textcomplete-item:hover,
.textcomplete-menu .active, .textcomplete-menu .active,
#chat .userlist .user.active { #chat .userlist .user.active {
background-color: rgba(0, 0, 0, 0.1); background-color: rgba(0, 0, 0, 0.1);
transition: none; transition: none;
outline: 0;
} }
.context-menu-item::before, .context-menu-item::before,
@ -2625,6 +2619,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
#upload-overlay, #upload-overlay,
#image-viewer, #image-viewer,
#image-viewer .open-btn,
#image-viewer .close-btn { #image-viewer .close-btn {
/* Vertically and horizontally center stuff */ /* Vertically and horizontally center stuff */
display: flex; display: flex;
@ -2645,6 +2640,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
opacity: 0; opacity: 0;
transition: opacity 0.2s, visibility 0.2s; transition: opacity 0.2s, visibility 0.2s;
z-index: 999; z-index: 999;
user-select: none;
} }
#image-viewer.opened { #image-viewer.opened {
@ -2658,6 +2654,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
} }
#image-viewer .close-btn, #image-viewer .close-btn,
#image-viewer .open-btn,
#image-viewer .previous-image-btn, #image-viewer .previous-image-btn,
#image-viewer .next-image-btn { #image-viewer .next-image-btn {
position: fixed; position: fixed;
@ -2679,6 +2676,14 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
content: "×"; content: "×";
} }
#image-viewer .open-btn {
right: 0;
bottom: 0;
top: auto;
height: 2em;
z-index: 1002;
}
#image-viewer .previous-image-btn, #image-viewer .previous-image-btn,
#image-viewer .next-image-btn { #image-viewer .next-image-btn {
bottom: 0; bottom: 0;
@ -2699,19 +2704,10 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
opacity: 1; opacity: 1;
} }
#image-viewer .image-link { #image-viewer > img {
margin: 10px; cursor: grab;
} position: absolute;
transform-origin: 50% 50%;
#image-viewer .image-link:hover {
opacity: 1;
}
#image-viewer .image-link img {
max-width: 100%;
/* Top/Bottom margins + button height + image/button margin */
max-height: calc(100vh - 2 * 10px - 37px - 10px);
/* Checkered background for transparent images */ /* Checkered background for transparent images */
background-position: 0 0, 10px 10px; background-position: 0 0, 10px 10px;
@ -2721,10 +2717,6 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
linear-gradient(45deg, #eee 25%, #fff 25%, #fff 75%, #eee 75%, #eee 100%); linear-gradient(45deg, #eee 25%, #fff 25%, #fff 75%, #eee 75%, #eee 100%);
} }
#image-viewer .open-btn {
margin: 0 auto 10px;
}
/* Correctly handle multiple successive whitespace characters. /* Correctly handle multiple successive whitespace characters.
For example: user has quit ( ===> L O L <=== ) */ For example: user has quit ( ===> L O L <=== ) */

View File

@ -47,27 +47,22 @@
<meta name="theme-color" content="<%- themeColor %>"> <meta name="theme-color" content="<%- themeColor %>">
</head> </head>
<body class="signed-out<%- public ? " public" : "" %>" data-transports="<%- JSON.stringify(transports) %>"> <body class="<%- public ? " public" : "" %>" data-transports="<%- JSON.stringify(transports) %>">
<div id="viewport"></div>
<div id="loading"> <div id="loading">
<div class="window"> <div class="window">
<div id="loading-status-container"> <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.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"> <img src="img/logo-vertical-transparent-bg-inverted.svg" class="logo-inverted" alt="The Lounge" width="256" height="170">
<p id="loading-page-message"><a href="https://enable-javascript.com/" target="_blank" rel="noopener">Your JavaScript must be enabled.</a></p> <p id="loading-page-message">The Lounge requires a modern browser with JavaScript enabled.</p>
</div> </div>
<div id="loading-reload-container"> <div id="loading-reload-container">
<p id="loading-slow">This is taking longer than it should, there might be connectivity issues.</p> <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> <button id="loading-reload" class="btn">Reload page</button>
</div> </div>
<script async src="js/loading-error-handlers.js?v=<%- cacheBust %>"></script>
</div> </div>
</div> </div>
<div id="viewport"></div> <script src="js/loading-error-handlers.js?v=<%- cacheBust %>"></script>
<div id="context-menu-container"></div>
<div id="image-viewer"></div>
<div id="upload-overlay"></div>
<script src="js/bundle.vendor.js?v=<%- cacheBust %>"></script> <script src="js/bundle.vendor.js?v=<%- cacheBust %>"></script>
<script src="js/bundle.js?v=<%- cacheBust %>"></script> <script src="js/bundle.js?v=<%- cacheBust %>"></script>
</body> </body>

View File

@ -1,11 +1,11 @@
"use strict"; "use strict";
const localStorage = require("./localStorage"); import storage from "./localStorage";
const location = require("./location"); import location from "./location";
module.exports = class Auth { export default class Auth {
static signout() { static signout() {
localStorage.clear(); storage.clear();
location.reload(); location.reload();
} }
}; }

View File

@ -1,30 +1,14 @@
"use strict"; "use strict";
const $ = require("jquery"); import Mousetrap from "mousetrap";
const fuzzy = require("fuzzy"); import {Textcomplete, Textarea} from "textcomplete";
const Mousetrap = require("mousetrap"); import fuzzy from "fuzzy";
const {Textcomplete, Textarea} = require("textcomplete");
const emojiMap = require("./libs/simplemap.json");
const constants = require("./constants");
const {vueApp} = require("./vue");
let input; import emojiMap from "./helpers/simplemap.json";
let textcomplete; import constants from "./constants";
let enabled = false; import store from "./store";
module.exports = { export default enableAutocomplete;
enable: enableAutocomplete,
disable() {
if (enabled) {
$("#form").off("submit.tabcomplete");
input.off("input.tabcomplete");
Mousetrap(input.get(0)).unbind("tab", "keydown");
textcomplete.destroy();
enabled = false;
vueApp.isAutoCompleting = false;
}
},
};
const emojiSearchTerms = Object.keys(emojiMap); const emojiSearchTerms = Object.keys(emojiMap);
const emojiStrategy = { const emojiStrategy = {
@ -61,17 +45,17 @@ const nicksStrategy = {
}, },
replace([, original], position = 1) { replace([, original], position = 1) {
// If no postfix specified, return autocompleted nick as-is // If no postfix specified, return autocompleted nick as-is
if (!vueApp.settings.nickPostfix) { if (!store.state.settings.nickPostfix) {
return original; return original;
} }
// If there is whitespace in the input already, append space to nick // If there is whitespace in the input already, append space to nick
if (position > 0 && /\s/.test($("#input").val())) { if (position > 0 && /\s/.test(store.state.activeChannel.channel.pendingMessage)) {
return original + " "; return original + " ";
} }
// If nick is first in the input, append specified postfix // If nick is first in the input, append specified postfix
return original + vueApp.settings.nickPostfix; return original + store.state.settings.nickPostfix;
}, },
index: 1, index: 1,
}; };
@ -175,14 +159,12 @@ const backgroundColorStrategy = {
index: 2, index: 2,
}; };
function enableAutocomplete(inputRef) { function enableAutocomplete(input) {
enabled = true;
let tabCount = 0; let tabCount = 0;
let lastMatch = ""; let lastMatch = "";
let currentMatches = []; let currentMatches = [];
input = $(inputRef);
input.on("input.tabcomplete", (e) => { input.addEventListener("input", (e) => {
if (e.detail === "autocomplete") { if (e.detail === "autocomplete") {
return; return;
} }
@ -192,21 +174,20 @@ function enableAutocomplete(inputRef) {
lastMatch = ""; lastMatch = "";
}); });
Mousetrap(input.get(0)).bind( Mousetrap(input).bind(
"tab", "tab",
(e) => { (e) => {
if (vueApp.isAutoCompleting) { if (store.state.isAutoCompleting) {
return; return;
} }
e.preventDefault(); e.preventDefault();
const text = input.val(); const text = input.value;
const element = input.get(0);
if (tabCount === 0) { if (tabCount === 0) {
lastMatch = text lastMatch = text
.substring(0, element.selectionStart) .substring(0, input.selectionStart)
.split(/\s/) .split(/\s/)
.pop(); .pop();
@ -221,19 +202,19 @@ function enableAutocomplete(inputRef) {
} }
} }
const position = element.selectionStart - lastMatch.length; const position = input.selectionStart - lastMatch.length;
const newMatch = nicksStrategy.replace( const newMatch = nicksStrategy.replace(
[0, currentMatches[tabCount % currentMatches.length]], [0, currentMatches[tabCount % currentMatches.length]],
position position
); );
const remainder = text.substr(element.selectionStart); const remainder = text.substr(input.selectionStart);
input.val(text.substr(0, position) + newMatch + remainder); input.value = text.substr(0, position) + newMatch + remainder;
element.selectionStart -= remainder.length; input.selectionStart -= remainder.length;
element.selectionEnd = element.selectionStart; input.selectionEnd = input.selectionStart;
// Propagate change to Vue model // Propagate change to Vue model
element.dispatchEvent( input.dispatchEvent(
new CustomEvent("input", { new CustomEvent("input", {
detail: "autocomplete", detail: "autocomplete",
}) })
@ -245,8 +226,8 @@ function enableAutocomplete(inputRef) {
"keydown" "keydown"
); );
const editor = new Textarea(input.get(0)); const editor = new Textarea(input);
textcomplete = new Textcomplete(editor, { const textcomplete = new Textcomplete(editor, {
dropdown: { dropdown: {
className: "textcomplete-menu", className: "textcomplete-menu",
placement: "top", placement: "top",
@ -271,16 +252,22 @@ function enableAutocomplete(inputRef) {
}); });
textcomplete.on("show", () => { textcomplete.on("show", () => {
vueApp.isAutoCompleting = true; store.commit("isAutoCompleting", true);
}); });
textcomplete.on("hidden", () => { textcomplete.on("hidden", () => {
vueApp.isAutoCompleting = false; store.commit("isAutoCompleting", false);
}); });
$("#form").on("submit.tabcomplete", () => { return {
textcomplete.hide(); hide() {
}); textcomplete.hide();
},
destroy() {
textcomplete.destroy();
store.commit("isAutoCompleting", false);
},
};
} }
function fuzzyGrep(term, array) { function fuzzyGrep(term, array) {
@ -292,17 +279,17 @@ function fuzzyGrep(term, array) {
} }
function rawNicks() { function rawNicks() {
if (vueApp.activeChannel.channel.users.length > 0) { if (store.state.activeChannel.channel.users.length > 0) {
const users = vueApp.activeChannel.channel.users.slice(); const users = store.state.activeChannel.channel.users.slice();
return users.sort((a, b) => b.lastMessage - a.lastMessage).map((u) => u.nick); return users.sort((a, b) => b.lastMessage - a.lastMessage).map((u) => u.nick);
} }
const me = vueApp.activeChannel.network.nick; const me = store.state.activeChannel.network.nick;
const otherUser = vueApp.activeChannel.channel.name; const otherUser = store.state.activeChannel.channel.name;
// If this is a query, add their name to autocomplete // If this is a query, add their name to autocomplete
if (me !== otherUser && vueApp.activeChannel.channel.type === "query") { if (me !== otherUser && store.state.activeChannel.channel.type === "query") {
return [otherUser, me]; return [otherUser, me];
} }
@ -318,7 +305,7 @@ function completeNicks(word, isFuzzy) {
return fuzzyGrep(word, users); return fuzzyGrep(word, users);
} }
return $.grep(users, (w) => !w.toLowerCase().indexOf(word)); return users.filter((w) => !w.toLowerCase().indexOf(word));
} }
function completeCommands(word) { function completeCommands(word) {
@ -330,7 +317,7 @@ function completeCommands(word) {
function completeChans(word) { function completeChans(word) {
const words = []; const words = [];
for (const channel of vueApp.activeChannel.network.channels) { for (const channel of store.state.activeChannel.network.channels) {
if (channel.type === "channel") { if (channel.type === "channel") {
words.push(channel.name); words.push(channel.name);
} }

View File

@ -1,6 +1,6 @@
"use strict"; "use strict";
module.exports = function(chat) { export default function(chat) {
// Disable in Firefox as it already copies flex text correctly // Disable in Firefox as it already copies flex text correctly
if (typeof window.InstallTrigger !== "undefined") { if (typeof window.InstallTrigger !== "undefined") {
return; return;
@ -28,4 +28,4 @@ module.exports = function(chat) {
selection.removeAllRanges(); selection.removeAllRanges();
selection.addRange(range); selection.addRange(range);
}, 0); }, 0);
}; }

View File

@ -1,8 +1,36 @@
"use strict"; "use strict";
const $ = require("jquery"); 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,
});
}
exports.input = function() {
$(".chan.active .toggle-button.toggle-preview.opened").click();
return true; return true;
}; }
export default {input};

View File

@ -1,8 +1,36 @@
"use strict"; "use strict";
const $ = require("jquery"); 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,
});
}
exports.input = function() {
$(".chan.active .toggle-button.toggle-preview:not(.opened)").click();
return true; return true;
}; }
export default {input};

View File

@ -8,14 +8,14 @@
// Second argument says it's recursive, third makes sure we only load javascript. // Second argument says it's recursive, third makes sure we only load javascript.
const commands = require.context("./", true, /\.js$/); const commands = require.context("./", true, /\.js$/);
module.exports = commands.keys().reduce((acc, path) => { export default commands.keys().reduce((acc, path) => {
const command = path.substring(2, path.length - 3); const command = path.substring(2, path.length - 3);
if (command === "index") { if (command === "index") {
return acc; return acc;
} }
acc[command] = commands(path); acc[command] = commands(path).default;
return acc; return acc;
}, {}); }, {});

View File

@ -1,17 +1,15 @@
"use strict"; "use strict";
const $ = require("jquery"); import socket from "../socket";
import store from "../store";
exports.input = function(args) { import {switchToChannel} from "../router";
const utils = require("../utils");
const socket = require("../socket");
const {vueApp} = require("../vue");
function input(args) {
if (args.length > 0) { if (args.length > 0) {
let channels = args[0]; let channels = args[0];
if (channels.length > 0) { if (channels.length > 0) {
const chanTypes = vueApp.activeChannel.network.serverOptions.CHANTYPES; const chanTypes = store.state.activeChannel.network.serverOptions.CHANTYPES;
const channelList = args[0].split(","); const channelList = args[0].split(",");
if (chanTypes && chanTypes.length > 0) { if (chanTypes && chanTypes.length > 0) {
@ -24,26 +22,28 @@ exports.input = function(args) {
channels = channelList.join(","); channels = channelList.join(",");
const chan = utils.findCurrentNetworkChan(channels); const chan = store.getters.findChannelOnCurrentNetwork(channels);
if (chan) { if (chan) {
$(`#sidebar .chan[data-id="${chan.id}"]`).trigger("click"); switchToChannel(chan);
} else { } else {
socket.emit("input", { socket.emit("input", {
text: `/join ${channels} ${args.length > 1 ? args[1] : ""}`, text: `/join ${channels} ${args.length > 1 ? args[1] : ""}`,
target: vueApp.activeChannel.channel.id, target: store.state.activeChannel.channel.id,
}); });
return true; return true;
} }
} }
} else if (vueApp.activeChannel.channel.type === "channel") { } else if (store.state.activeChannel.channel.type === "channel") {
// If `/join` command is used without any arguments, re-join current channel // If `/join` command is used without any arguments, re-join current channel
socket.emit("input", { socket.emit("input", {
target: vueApp.activeChannel.channel.id, target: store.state.activeChannel.channel.id,
text: `/join ${vueApp.activeChannel.channel.name}`, text: `/join ${store.state.activeChannel.channel.name}`,
}); });
return true; return true;
} }
}; }
export default {input};

View File

@ -29,11 +29,13 @@ const timeFormats = {
const sizeUnits = ["B", "KiB", "MiB", "GiB", "TiB"]; const sizeUnits = ["B", "KiB", "MiB", "GiB", "TiB"];
module.exports = { export default {
colorCodeMap, colorCodeMap,
commands: [], commands: [],
condensedTypes, condensedTypes,
condensedTypesQuery, condensedTypesQuery,
timeFormats, timeFormats,
sizeUnits, sizeUnits,
// Same value as media query in CSS that forces sidebars to become overlays
mobileViewportPixels: 768,
}; };

View File

@ -1,168 +0,0 @@
"use strict";
const $ = require("jquery");
const Mousetrap = require("mousetrap");
const templates = require("../views");
const contextMenuContainer = $("#context-menu-container");
module.exports = class ContextMenu {
constructor(contextMenuItems, contextMenuActions, selectedElement, event) {
this.previousActiveElement = document.activeElement;
this.contextMenuItems = contextMenuItems;
this.contextMenuActions = contextMenuActions;
this.selectedElement = selectedElement;
this.event = event;
}
show() {
const contextMenu = showContextMenu(
this.contextMenuItems,
this.selectedElement,
this.event
);
this.bindEvents(contextMenu);
return false;
}
hide() {
contextMenuContainer
.hide()
.empty()
.off(".contextMenu");
Mousetrap.unbind("escape");
}
bindEvents(contextMenu) {
const contextMenuActions = this.contextMenuActions;
contextMenuActions.execute = (id, ...args) =>
contextMenuActions[id] && contextMenuActions[id](...args);
const clickItem = (item) => {
const itemData = item.attr("data-data");
const contextAction = item.attr("data-action");
this.hide();
contextMenuActions.execute(contextAction, itemData);
};
contextMenu.on("click", ".context-menu-item", function() {
clickItem($(this));
});
const trap = Mousetrap(contextMenu.get(0));
trap.bind(["up", "down"], (e, key) => {
const items = contextMenu.find(".context-menu-item");
let index = items.toArray().findIndex((item) => $(item).is(":focus"));
if (key === "down") {
index = (index + 1) % items.length;
} else {
index = Math.max(index, 0) - 1;
}
items.eq(index).trigger("focus");
});
trap.bind("enter", () => {
const item = contextMenu.find(".context-menu-item:focus");
if (item.length) {
clickItem(item);
}
return false;
});
// Hide context menu when clicking or right clicking outside of it
contextMenuContainer.on("click.contextMenu contextmenu.contextMenu", (e) => {
// Do not close the menu when clicking inside of the context menu (e.g. on a divider)
if ($(e.target).prop("id") === "context-menu") {
return;
}
this.hide();
return false;
});
// Hide the context menu when pressing escape within the context menu container
Mousetrap.bind("escape", () => {
this.hide();
// Return focus to the previously focused element
$(this.previousActiveElement).trigger("focus");
return false;
});
}
};
function showContextMenu(contextMenuItems, selectedElement, event) {
const target = $(event.currentTarget);
const contextMenu = $("<ul>", {
id: "context-menu",
role: "menu",
});
for (const item of contextMenuItems) {
if (item.check(target)) {
if (item.divider) {
contextMenu.append(templates.contextmenu_divider());
} else {
contextMenu.append(
templates.contextmenu_item({
class:
typeof item.className === "function"
? item.className(target)
: item.className,
action: item.actionId,
text:
typeof item.displayName === "function"
? item.displayName(target)
: item.displayName,
data: typeof item.data === "function" ? item.data(target) : item.data,
})
);
}
}
}
contextMenuContainer.html(contextMenu).show();
contextMenu
.css(positionContextMenu(contextMenu, selectedElement, event))
.find(".context-menu-item:first-child")
.trigger("focus");
return contextMenu;
}
function positionContextMenu(contextMenu, selectedElement, e) {
let offset;
const menuWidth = contextMenu.outerWidth();
const menuHeight = contextMenu.outerHeight();
if (selectedElement.hasClass("menu")) {
offset = selectedElement.offset();
offset.left -= menuWidth - selectedElement.outerWidth();
offset.top += selectedElement.outerHeight();
return offset;
}
offset = {left: e.pageX, top: e.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;
}

View File

@ -1,404 +0,0 @@
"use strict";
const $ = require("jquery");
const socket = require("./socket");
const utils = require("./utils");
const ContextMenu = require("./contextMenu");
const contextMenuActions = [];
const contextMenuItems = [];
const {vueApp, findChannel} = require("./vue");
module.exports = {
addContextMenuItem,
createContextMenu,
};
addDefaultItems();
/**
* Used for adding context menu items. eg:
*
* addContextMenuItem({
* check: (target) => target.hasClass("user"),
* className: "customItemName",
* data: (target) => target.attr("data-name"),
* displayName: "Do something",
* callback: (name) => console.log(name), // print the name of the user to console
* });
*
* @param opts
* @param {function(Object)} [opts.check] - Function to check whether item should show on the context menu, called with the target jquery element, shows if return is truthy
* @param {string|function(Object)} opts.className - class name for the menu item, should be prefixed for non-default menu items (if function, called with jquery element, and uses return value)
* @param {string|function(Object)} opts.data - data that will be sent to the callback function (if function, called with jquery element, and uses return value)
* @param {string|function(Object)} opts.displayName - text to display on the menu item (if function, called with jquery element, and uses return value)
* @param {function(Object)} opts.callback - Function to call when the context menu item is clicked, called with the data requested in opts.data
*/
function addContextMenuItem(opts) {
opts.check = opts.check || (() => true);
opts.actionId = contextMenuActions.push(opts.callback) - 1;
contextMenuItems.push(opts);
}
function addContextDivider(opts) {
opts.check = opts.check || (() => true);
opts.divider = true;
contextMenuItems.push(opts);
}
function createContextMenu(that, event) {
return new ContextMenu(contextMenuItems, contextMenuActions, that, event);
}
function addWhoisItem() {
function whois(itemData) {
const chan = utils.findCurrentNetworkChan(itemData);
if (chan) {
$(`#sidebar .chan[data-id="${chan.id}"]`).trigger("click");
}
socket.emit("input", {
target: Number($("#chat").attr("data-id")),
text: "/whois " + itemData,
});
}
addContextMenuItem({
check: (target) => target.hasClass("user"),
className: "user",
displayName: (target) => target.attr("data-name"),
data: (target) => target.attr("data-name"),
callback: whois,
});
addContextDivider({
check: (target) => target.hasClass("user"),
});
addContextMenuItem({
check: (target) => target.hasClass("user") || target.hasClass("query"),
className: "action-whois",
displayName: "User information",
data: (target) => target.attr("data-name") || target.attr("aria-label"),
callback: whois,
});
}
function addQueryItem() {
function query(itemData) {
const chan = utils.findCurrentNetworkChan(itemData);
if (chan) {
$(`#sidebar .chan[data-id="${chan.id}"]`).trigger("click");
}
socket.emit("input", {
target: Number($("#chat").attr("data-id")),
text: "/query " + itemData,
});
}
addContextMenuItem({
check: (target) => target.hasClass("user"),
className: "action-query",
displayName: "Direct messages",
data: (target) => target.attr("data-name"),
callback: query,
});
}
function addCloseItem() {
function getCloseDisplay(target) {
if (target.hasClass("lobby")) {
return "Remove";
} else if (target.hasClass("channel")) {
return "Leave";
}
return "Close";
}
addContextMenuItem({
check: (target) => target.hasClass("chan"),
className: "close",
displayName: getCloseDisplay,
data: (target) => target.attr("data-target"),
callback: (itemData) => utils.closeChan($(`.networks .chan[data-target="${itemData}"]`)),
});
}
function addConnectItem() {
function connect(itemData) {
socket.emit("input", {
target: Number(itemData),
text: "/connect",
});
}
addContextMenuItem({
check: (target) => target.hasClass("lobby") && target.parent().hasClass("not-connected"),
className: "connect",
displayName: "Connect",
data: (target) => target.attr("data-id"),
callback: connect,
});
}
function addDisconnectItem() {
function disconnect(itemData) {
socket.emit("input", {
target: Number(itemData),
text: "/disconnect",
});
}
addContextMenuItem({
check: (target) => target.hasClass("lobby") && !target.parent().hasClass("not-connected"),
className: "disconnect",
displayName: "Disconnect",
data: (target) => target.attr("data-id"),
callback: disconnect,
});
}
function addKickItem() {
function kick(itemData) {
socket.emit("input", {
target: Number($("#chat").attr("data-id")),
text: "/kick " + itemData,
});
}
addContextMenuItem({
check: (target) =>
utils.hasRoleInChannel(target.closest(".chan"), ["op"]) &&
target.closest(".chan").attr("data-type") === "channel",
className: "action-kick",
displayName: "Kick",
data: (target) => target.attr("data-name"),
callback: kick,
});
}
function addOpItem() {
function op(itemData) {
socket.emit("input", {
target: Number($("#chat").attr("data-id")),
text: "/op " + itemData,
});
}
addContextMenuItem({
check: (target) =>
utils.hasRoleInChannel(target.closest(".chan"), ["op"]) &&
!utils.hasRoleInChannel(target.closest(".chan"), ["op"], target.attr("data-name")),
className: "action-op",
displayName: "Give operator (+o)",
data: (target) => target.attr("data-name"),
callback: op,
});
}
function addDeopItem() {
function deop(itemData) {
socket.emit("input", {
target: Number($("#chat").attr("data-id")),
text: "/deop " + itemData,
});
}
addContextMenuItem({
check: (target) =>
utils.hasRoleInChannel(target.closest(".chan"), ["op"]) &&
utils.hasRoleInChannel(target.closest(".chan"), ["op"], target.attr("data-name")),
className: "action-op",
displayName: "Revoke operator (-o)",
data: (target) => target.attr("data-name"),
callback: deop,
});
}
function addVoiceItem() {
function voice(itemData) {
socket.emit("input", {
target: Number($("#chat").attr("data-id")),
text: "/voice " + itemData,
});
}
addContextMenuItem({
check: (target) =>
utils.hasRoleInChannel(target.closest(".chan"), ["op"]) &&
!utils.hasRoleInChannel(target.closest(".chan"), ["voice"], target.attr("data-name")),
className: "action-voice",
displayName: "Give voice (+v)",
data: (target) => target.attr("data-name"),
callback: voice,
});
}
function addDevoiceItem() {
function devoice(itemData) {
socket.emit("input", {
target: Number($("#chat").attr("data-id")),
text: "/devoice " + itemData,
});
}
addContextMenuItem({
check: (target) =>
utils.hasRoleInChannel(target.closest(".chan"), ["op"]) &&
utils.hasRoleInChannel(target.closest(".chan"), ["voice"], target.attr("data-name")),
className: "action-voice",
displayName: "Revoke voice (-v)",
data: (target) => target.attr("data-name"),
callback: devoice,
});
}
function addFocusItem() {
function focusChan(itemData) {
$(`.networks .chan[data-target="${itemData}"]`).click();
}
const getClass = (target) => {
if (target.hasClass("lobby")) {
return "network";
} else if (target.hasClass("query")) {
return "query";
}
return "chan";
};
addContextMenuItem({
check: (target) => target.hasClass("chan"),
className: getClass,
displayName: (target) => target.attr("data-name") || target.attr("aria-label"),
data: (target) => target.attr("data-target"),
callback: focusChan,
});
addContextDivider({
check: (target) => target.hasClass("chan"),
});
}
function addEditNetworkItem() {
function edit(itemData) {
socket.emit("network:get", itemData);
$('button[data-target="#connect"]').trigger("click");
}
addContextMenuItem({
check: (target) => target.hasClass("lobby"),
className: "edit",
displayName: "Edit this network…",
data: (target) => target.closest(".network").attr("data-uuid"),
callback: edit,
});
}
function addChannelListItem() {
function list(itemData) {
socket.emit("input", {
target: parseInt(itemData, 10),
text: "/list",
});
}
addContextMenuItem({
check: (target) => target.hasClass("lobby"),
className: "list",
displayName: "List all channels",
data: (target) => target.attr("data-id"),
callback: list,
});
}
function addEditTopicItem() {
function setEditTopic(itemData) {
findChannel(Number(itemData)).channel.editTopic = true;
document.querySelector(`#sidebar .chan[data-id="${Number(itemData)}"]`).click();
vueApp.$nextTick(() => {
document.querySelector(`#chan-${Number(itemData)} .topic-input`).focus();
});
}
addContextMenuItem({
check: (target) => target.hasClass("channel"),
className: "edit",
displayName: "Edit topic",
data: (target) => target.attr("data-id"),
callback: setEditTopic,
});
}
function addBanListItem() {
function banlist(itemData) {
socket.emit("input", {
target: parseInt(itemData, 10),
text: "/banlist",
});
}
addContextMenuItem({
check: (target) => target.hasClass("channel"),
className: "list",
displayName: "List banned users",
data: (target) => target.attr("data-id"),
callback: banlist,
});
}
function addJoinItem() {
function openJoinForm(itemData) {
findChannel(Number(itemData)).network.isJoinChannelShown = true;
}
addContextMenuItem({
check: (target) => target.hasClass("lobby"),
className: "join",
displayName: "Join a channel…",
data: (target) => target.attr("data-id"),
callback: openJoinForm,
});
}
function addIgnoreListItem() {
function ignorelist(itemData) {
socket.emit("input", {
target: parseInt(itemData, 10),
text: "/ignorelist",
});
}
addContextMenuItem({
check: (target) => target.hasClass("lobby"),
className: "list",
displayName: "List ignored users",
data: (target) => target.attr("data-id"),
callback: ignorelist,
});
}
function addDefaultItems() {
addFocusItem();
addWhoisItem();
addQueryItem();
addKickItem();
addOpItem();
addDeopItem();
addVoiceItem();
addDevoiceItem();
addEditNetworkItem();
addJoinItem();
addChannelListItem();
addEditTopicItem();
addBanListItem();
addIgnoreListItem();
addConnectItem();
addDisconnectItem();
addCloseItem();
}

View File

@ -1,7 +1,7 @@
"use strict"; "use strict";
// Generates a string from "color-1" to "color-32" based on an input string // Generates a string from "color-1" to "color-32" based on an input string
module.exports = function(str) { export default (str) => {
let hash = 0; let hash = 0;
for (let i = 0; i < str.length; i++) { for (let i = 0; i < str.length; i++) {

View File

@ -0,0 +1,295 @@
"use strict";
import socket from "../socket";
export function generateChannelContextMenu($root, channel, network) {
const typeMap = {
lobby: "network",
channel: "chan",
query: "query",
special: "chan",
};
const closeMap = {
lobby: "Remove",
channel: "Leave",
query: "Close",
special: "Close",
};
let items = [
{
label: channel.name,
type: "item",
class: typeMap[channel.type],
link: `/chan-${channel.id}`,
},
{
type: "divider",
},
];
// Add menu items for lobbies
if (channel.type === "lobby") {
items = [
...items,
{
label: "Edit this network…",
type: "item",
class: "edit",
link: `/edit-network/${network.uuid}`,
},
{
label: "Join a channel…",
type: "item",
class: "join",
action: () => (network.isJoinChannelShown = true),
},
{
label: "List all channels",
type: "item",
class: "list",
action: () =>
socket.emit("input", {
target: channel.id,
text: "/list",
}),
},
{
label: "List ignored users",
type: "item",
class: "list",
action: () =>
socket.emit("input", {
target: channel.id,
text: "/ignorelist",
}),
},
network.status.connected
? {
label: "Disconnect",
type: "item",
class: "disconnect",
action: () =>
socket.emit("input", {
target: channel.id,
text: "/disconnect",
}),
}
: {
label: "Connect",
type: "item",
class: "connect",
action: () =>
socket.emit("input", {
target: channel.id,
text: "/connect",
}),
},
];
}
// Add menu items for channels
if (channel.type === "channel") {
items.push({
label: "Edit topic",
type: "item",
class: "edit",
action() {
channel.editTopic = true;
$root.switchToChannel(channel);
$root.$nextTick(() =>
document.querySelector(`#chan-${channel.id} .topic-input`).focus()
);
},
});
items.push({
label: "List banned users",
type: "item",
class: "list",
action() {
socket.emit("input", {
target: channel.id,
text: "/banlist",
});
},
});
}
// Add menu items for queries
if (channel.type === "query") {
items.push({
label: "User information",
type: "item",
class: "action-whois",
action() {
$root.switchToChannel(channel);
socket.emit("input", {
target: channel.id,
text: "/whois " + channel.name,
});
},
});
}
// Add close menu item
items.push({
label: closeMap[channel.type],
type: "item",
class: "close",
action() {
$root.closeChannel(channel);
},
});
return items;
}
export function generateUserContextMenu($root, channel, network, user) {
const currentChannelUser = channel.users.find((u) => u.nick === network.nick) || {};
const whois = () => {
const chan = $root.$store.getters.findChannelOnCurrentNetwork(user.nick);
if (chan) {
$root.switchToChannel(chan);
}
socket.emit("input", {
target: channel.id,
text: "/whois " + user.nick,
});
};
const items = [
{
label: user.nick,
type: "item",
class: "user",
action: whois,
},
{
type: "divider",
},
{
label: "User information",
type: "item",
class: "action-whois",
action: whois,
},
{
label: "Direct messages",
type: "item",
class: "action-query",
action() {
const chan = $root.$store.getters.findChannelOnCurrentNetwork(user.nick);
if (chan) {
$root.switchToChannel(chan);
}
socket.emit("input", {
target: channel.id,
text: "/query " + user.nick,
});
},
},
];
if (currentChannelUser.mode === "@") {
items.push({
label: "Kick",
type: "item",
class: "action-kick",
action() {
socket.emit("input", {
target: channel.id,
text: "/kick " + user.nick,
});
},
});
if (user.mode === "@") {
items.push({
label: "Revoke operator (-o)",
type: "item",
class: "action-op",
action() {
socket.emit("input", {
target: channel.id,
text: "/deop " + user.nick,
});
},
});
} else {
items.push({
label: "Give operator (+o)",
type: "item",
class: "action-op",
action() {
socket.emit("input", {
target: channel.id,
text: "/op " + user.nick,
});
},
});
}
if (user.mode === "+") {
items.push({
label: "Revoke voice (-v)",
type: "item",
class: "action-voice",
action() {
socket.emit("input", {
target: channel.id,
text: "/devoice " + user.nick,
});
},
});
} else {
items.push({
label: "Give voice (+v)",
type: "item",
class: "action-voice",
action() {
socket.emit("input", {
target: channel.id,
text: "/voice " + user.nick,
});
},
});
}
}
return items;
}
export function generateRemoveNetwork($root, lobby) {
return [
{
label: lobby.name,
type: "item",
class: "network",
},
{
type: "divider",
},
{
label: "Yes, remove this",
type: "item",
action() {
lobby.closed = true;
socket.emit("input", {
target: Number(lobby.id),
text: "/quit",
});
},
},
{
label: "Cancel",
type: "item",
},
];
}

View File

@ -2,7 +2,7 @@
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
module.exports = function(size) { export default (size) => {
// Loosely inspired from https://stackoverflow.com/a/18650828/1935861 // Loosely inspired from https://stackoverflow.com/a/18650828/1935861
const i = size > 0 ? Math.floor(Math.log(size) / Math.log(1024)) : 0; const i = size > 0 ? Math.floor(Math.log(size) / Math.log(1024)) : 0;
const fixedSize = parseFloat((size / Math.pow(1024, i)).toFixed(1)); const fixedSize = parseFloat((size / Math.pow(1024, i)).toFixed(1));

View File

@ -11,4 +11,4 @@ function anyIntersection(a, b) {
); );
} }
module.exports = anyIntersection; export default anyIntersection;

View File

@ -32,4 +32,4 @@ function fill(existingEntries, text) {
return result; return result;
} }
module.exports = fill; export default fill;

View File

@ -3,7 +3,7 @@
// Escapes the RegExp special characters "^", "$", "", ".", "*", "+", "?", "(", // Escapes the RegExp special characters "^", "$", "", ".", "*", "+", "?", "(",
// ")", "[", "]", "{", "}", and "|" in string. // ")", "[", "]", "{", "}", and "|" in string.
// See https://lodash.com/docs/#escapeRegExp // See https://lodash.com/docs/#escapeRegExp
const escapeRegExp = require("lodash/escapeRegExp"); import escapeRegExp from "lodash/escapeRegExp";
// Given an array of channel prefixes (such as "#" and "&") and an array of user // Given an array of channel prefixes (such as "#" and "&") and an array of user
// modes (such as "@" and "+"), this function extracts channels and nicks from a // modes (such as "@" and "+"), this function extracts channels and nicks from a
@ -40,4 +40,4 @@ function findChannels(text, channelPrefixes, userModes) {
return result; return result;
} }
module.exports = findChannels; export default findChannels;

View File

@ -17,4 +17,4 @@ function findEmoji(text) {
return result; return result;
} }
module.exports = findEmoji; export default findEmoji;

View File

@ -25,4 +25,4 @@ function findNames(text, users) {
return result; return result;
} }
module.exports = findNames; export default findNames;

View File

@ -1,7 +1,7 @@
"use strict"; "use strict";
const anyIntersection = require("./anyIntersection"); import anyIntersection from "./anyIntersection";
const fill = require("./fill"); import fill from "./fill";
// Merge text part information within a styling fragment // Merge text part information within a styling fragment
function assign(textPart, fragment) { function assign(textPart, fragment) {
@ -51,4 +51,4 @@ function merge(textParts, styleFragments, cleanText) {
}); });
} }
module.exports = merge; export default merge;

View File

@ -234,4 +234,4 @@ function prepare(text) {
); );
} }
module.exports = prepare; export default prepare;

View File

@ -0,0 +1,15 @@
"use strict";
import store from "../store";
export default (network, channel) => {
if (!network.isCollapsed || channel.highlight || channel.type === "lobby") {
return false;
}
if (store.state.activeChannel && channel === store.state.activeChannel.channel) {
return false;
}
return true;
};

View File

@ -0,0 +1,5 @@
"use strict";
import dayjs from "dayjs";
export default (time) => dayjs(time).format("D MMMM YYYY, HH:mm:ss");

View File

@ -1,15 +1,17 @@
"use strict"; "use strict";
const parseStyle = require("./ircmessageparser/parseStyle"); import parseStyle from "./ircmessageparser/parseStyle";
const findChannels = require("./ircmessageparser/findChannels"); import findChannels from "./ircmessageparser/findChannels";
const findLinks = require("./ircmessageparser/findLinks"); import findLinks from "./ircmessageparser/findLinks";
const findEmoji = require("./ircmessageparser/findEmoji"); import findEmoji from "./ircmessageparser/findEmoji";
const findNames = require("./ircmessageparser/findNames"); import findNames from "./ircmessageparser/findNames";
const merge = require("./ircmessageparser/merge"); import merge from "./ircmessageparser/merge";
const colorClass = require("./colorClass"); import emojiMap from "./fullnamemap.json";
const emojiMap = require("../fullnamemap.json"); import LinkPreviewToggle from "../../components/LinkPreviewToggle.vue";
const LinkPreviewToggle = require("../../../components/LinkPreviewToggle.vue").default; import LinkPreviewFileSize from "../../components/LinkPreviewFileSize.vue";
const LinkPreviewFileSize = require("../../../components/LinkPreviewFileSize.vue").default; import InlineChannel from "../../components/InlineChannel.vue";
import Username from "../../components/Username.vue";
const emojiModifiersRegex = /[\u{1f3fb}-\u{1f3ff}]/gu; const emojiModifiersRegex = /[\u{1f3fb}-\u{1f3ff}]/gu;
// Create an HTML `span` with styling information for a given fragment // Create an HTML `span` with styling information for a given fragment
@ -68,7 +70,7 @@ function createFragment(fragment, createElement) {
// Transform an IRC message potentially filled with styling control codes, URLs, // Transform an IRC message potentially filled with styling control codes, URLs,
// nicknames, and channels into a string of HTML elements to display on the client. // nicknames, and channels into a string of HTML elements to display on the client.
module.exports = function parse(createElement, text, message = undefined, network = undefined) { function parse(createElement, text, message = undefined, network = undefined) {
// Extract the styling information and get the plain text version from it // Extract the styling information and get the plain text version from it
const styleFragments = parseStyle(text); const styleFragments = parseStyle(text);
const cleanText = styleFragments.map((fragment) => fragment.text).join(""); const cleanText = styleFragments.map((fragment) => fragment.text).join("");
@ -151,14 +153,10 @@ module.exports = function parse(createElement, text, message = undefined, networ
); );
} else if (textPart.channel) { } else if (textPart.channel) {
return createElement( return createElement(
"span", InlineChannel,
{ {
class: ["inline-channel"], props: {
attrs: { channel: textPart.channel,
role: "button",
dir: "auto",
tabindex: 0,
"data-chan": textPart.channel,
}, },
}, },
fragments fragments
@ -183,13 +181,15 @@ module.exports = function parse(createElement, text, message = undefined, networ
); );
} else if (textPart.nick) { } else if (textPart.nick) {
return createElement( return createElement(
"span", Username,
{ {
class: ["user", colorClass(textPart.nick)], props: {
user: {
nick: textPart.nick,
},
},
attrs: { attrs: {
role: "button",
dir: "auto", dir: "auto",
"data-name": textPart.nick,
}, },
}, },
fragments fragments
@ -198,4 +198,6 @@ module.exports = function parse(createElement, text, message = undefined, networ
return fragments; return fragments;
}); });
}; }
export default parse;

View File

@ -0,0 +1,56 @@
"use strict";
export default (stringUri) => {
const data = {};
try {
// https://tools.ietf.org/html/draft-butcher-irc-url-04
const uri = new URL(stringUri);
// Replace protocol with a "special protocol" (that's what it's called in WHATWG spec)
// So that the uri can be properly parsed
if (uri.protocol === "irc:") {
uri.protocol = "http:";
if (!uri.port) {
uri.port = 6667;
}
data.tls = false;
} else if (uri.protocol === "ircs:") {
uri.protocol = "https:";
if (!uri.port) {
uri.port = 6697;
}
data.tls = true;
} else {
return;
}
if (!uri.hostname) {
return {};
}
data.host = data.name = uri.hostname;
data.port = uri.port;
let channel = "";
if (uri.pathname.length > 1) {
channel = uri.pathname.substr(1); // Remove slash
}
if (uri.hash.length > 1) {
channel += uri.hash;
}
// We don't split channels or append # here because the connect window takes care of that
data.join = channel;
} catch (e) {
// do nothing on invalid uri
}
return data;
};

View File

@ -1,6 +1,6 @@
"use strict"; "use strict";
module.exports = function(count) { export default (count) => {
if (count < 1000) { if (count < 1000) {
return count.toString(); return count.toString();
} }

View File

@ -1,72 +1,70 @@
"use strict"; "use strict";
const $ = require("jquery"); import Mousetrap from "mousetrap";
const Mousetrap = require("mousetrap");
const utils = require("./utils");
const {vueApp} = require("./vue");
import store from "./store";
import {switchToChannel} from "./router";
import isChannelCollapsed from "./helpers/isChannelCollapsed";
// Switch to the next/previous window in the channel list.
Mousetrap.bind(["alt+up", "alt+down"], function(e, keys) { Mousetrap.bind(["alt+up", "alt+down"], function(e, keys) {
const sidebar = $("#sidebar"); if (store.state.networks.length === 0) {
const channels = sidebar.find(".chan").not(".network.collapsed :not(.lobby)"); return false;
const index = channels.index(channels.filter(".active"));
const direction = keys.split("+").pop();
let target;
switch (direction) {
case "up":
target = (channels.length + (index - 1 + channels.length)) % channels.length;
break;
case "down":
target = (channels.length + (index + 1 + channels.length)) % channels.length;
break;
} }
target = channels.eq(target).click(); const direction = keys.split("+").pop() === "up" ? -1 : 1;
utils.scrollIntoViewNicely(target[0]); const flatChannels = [];
let index = -1;
for (const network of store.state.networks) {
for (const channel of network.channels) {
if (isChannelCollapsed(network, channel)) {
continue;
}
if (
index === -1 &&
store.state.activeChannel &&
store.state.activeChannel.channel === channel
) {
index = flatChannels.length;
}
flatChannels.push(channel);
}
}
// Circular array, and a modulo bug workaround because in JS it stays negative
const length = flatChannels.length;
index = (((index + direction) % length) + length) % length;
jumpToChannel(flatChannels[index]);
return false; return false;
}); });
// Switch to the next/previous lobby in the channel list
Mousetrap.bind(["alt+shift+up", "alt+shift+down"], function(e, keys) { Mousetrap.bind(["alt+shift+up", "alt+shift+down"], function(e, keys) {
const sidebar = $("#sidebar"); const length = store.state.networks.length;
const lobbies = sidebar.find(".lobby");
const direction = keys.split("+").pop();
let index = lobbies.index(lobbies.filter(".active"));
let target;
switch (direction) { if (length === 0) {
case "up": return false;
if (index < 0) {
target = lobbies.index(
sidebar
.find(".channel")
.filter(".active")
.siblings(".lobby")[0]
);
} else {
target = (lobbies.length + (index - 1 + lobbies.length)) % lobbies.length;
}
break;
case "down":
if (index < 0) {
index = lobbies.index(
sidebar
.find(".channel")
.filter(".active")
.siblings(".lobby")[0]
);
}
target = (lobbies.length + (index + 1 + lobbies.length)) % lobbies.length;
break;
} }
target = lobbies.eq(target).click(); const direction = keys.split("+").pop() === "up" ? -1 : 1;
utils.scrollIntoViewNicely(target[0]); let index = 0;
// If we're in another window, jump to first lobby
if (store.state.activeChannel) {
index = store.state.networks.findIndex((n) => n === store.state.activeChannel.network);
// If we're in a channel, and it's not the lobby, jump to lobby of this network when going up
if (direction !== -1 || store.state.activeChannel.channel.type === "lobby") {
index = (((index + direction) % length) + length) % length;
}
}
jumpToChannel(store.state.networks[index].channels[0]);
return false; return false;
}); });
@ -74,28 +72,36 @@ Mousetrap.bind(["alt+shift+up", "alt+shift+down"], function(e, keys) {
// Jump to the first window with a highlight in it, or the first with unread // Jump to the first window with a highlight in it, or the first with unread
// activity if there are none with highlights. // activity if there are none with highlights.
Mousetrap.bind(["alt+a"], function() { Mousetrap.bind(["alt+a"], function() {
let targetchan; let targetChannel;
outer_loop: for (const network of vueApp.networks) { outer_loop: for (const network of store.state.networks) {
for (const chan of network.channels) { for (const chan of network.channels) {
if (chan.highlight) { if (chan.highlight) {
targetchan = chan; targetChannel = chan;
break outer_loop; break outer_loop;
} }
if (chan.unread && !targetchan) { if (chan.unread && !targetChannel) {
targetchan = chan; targetChannel = chan;
} }
} }
} }
if (targetchan) { if (targetChannel) {
$(`#sidebar .chan[data-id="${targetchan.id}"]`).trigger("click"); jumpToChannel(targetChannel);
} }
return false; return false;
}); });
function jumpToChannel(targetChannel) {
switchToChannel(targetChannel);
scrollIntoViewNicely(
document.querySelector(`#sidebar .chan[aria-controls="#chan-${targetChannel.id}"]`)
);
}
// Ignored keys which should not automatically focus the input bar // Ignored keys which should not automatically focus the input bar
const ignoredKeys = { const ignoredKeys = {
8: true, // Backspace 8: true, // Backspace
@ -132,7 +138,7 @@ const ignoredKeys = {
224: true, // Meta 224: true, // Meta
}; };
$(document).on("keydown", (e) => { document.addEventListener("keydown", (e) => {
// Ignore any key that uses alt modifier // Ignore any key that uses alt modifier
// Ignore keys defined above // Ignore keys defined above
if (e.altKey || ignoredKeys[e.which]) { if (e.altKey || ignoredKeys[e.which]) {
@ -146,7 +152,12 @@ $(document).on("keydown", (e) => {
// Redirect pagedown/pageup keys to messages container so it scrolls // Redirect pagedown/pageup keys to messages container so it scrolls
if (e.which === 33 || e.which === 34) { if (e.which === 33 || e.which === 34) {
$("#windows .window.active .chan.active .chat").trigger("focus"); const chat = document.querySelector("#windows .chan.active .chat");
if (chat) {
chat.focus();
}
return; return;
} }
@ -157,14 +168,23 @@ $(document).on("keydown", (e) => {
return; return;
} }
const input = $("#input"); const input = document.getElementById("input");
if (!input) {
return;
}
input.focus();
// On enter, focus the input but do not propagate the event // On enter, focus the input but do not propagate the event
// This way, a new line is not inserted // This way, a new line is not inserted
if (e.which === 13) { if (e.which === 13) {
input.trigger("focus"); e.preventDefault();
return false;
} }
input.trigger("focus");
}); });
function scrollIntoViewNicely(el) {
// Ideally this would use behavior: "smooth", but that does not consistently work in e.g. Chrome
// https://github.com/iamdustan/smoothscroll/issues/28#issuecomment-364061459
el.scrollIntoView({block: "center", inline: "nearest"});
}

View File

@ -1,16 +0,0 @@
"use strict";
module.exports = function(a, b, opt) {
if (arguments.length !== 3) {
throw new Error("Handlebars helper `equal` expects 3 arguments");
}
a = a.toString();
b = b.toString();
if (a === b) {
return opt.fn(this);
}
return opt.inverse(this);
};

View File

@ -1,7 +0,0 @@
"use strict";
const moment = require("moment");
module.exports = function(time) {
return moment(time).format("D MMMM YYYY, HH:mm:ss");
};

View File

@ -1,4 +1,4 @@
/* eslint strict: 0, no-var: 0 */ /* eslint strict: 0 */
"use strict"; "use strict";
/* /*
@ -9,71 +9,66 @@
*/ */
(function() { (function() {
var msg = document.getElementById("loading-page-message"); const msg = document.getElementById("loading-page-message");
msg.textContent = "Loading the app…";
if (msg) { document
msg.textContent = "Loading the app…"; .getElementById("loading-reload")
.addEventListener("click", () => location.reload(true));
document.getElementById("loading-reload").addEventListener("click", function() { const displayReload = () => {
location.reload(true); const loadingReload = document.getElementById("loading-reload");
});
}
var displayReload = function displayReload() {
var loadingReload = document.getElementById("loading-reload");
if (loadingReload) { if (loadingReload) {
loadingReload.style.visibility = "visible"; loadingReload.style.visibility = "visible";
} }
}; };
var loadingSlowTimeout = setTimeout(function() { const loadingSlowTimeout = setTimeout(() => {
var loadingSlow = document.getElementById("loading-slow"); const loadingSlow = document.getElementById("loading-slow");
loadingSlow.style.visibility = "visible";
// The parent element, #loading, is being removed when the app is loaded. displayReload();
// Since the timer is not cancelled, `loadingSlow` can be not found after
// 5s. Wrap everything in this block to make sure nothing happens if the
// element does not exist (i.e. page has loaded).
if (loadingSlow) {
loadingSlow.style.visibility = "visible";
displayReload();
}
}, 5000); }, 5000);
window.g_LoungeErrorHandler = function LoungeErrorHandler(e) { const errorHandler = (e) => {
var message = document.getElementById("loading-page-message"); msg.textContent = "An error has occurred that prevented the client from loading correctly.";
message.textContent =
"An error has occurred that prevented the client from loading correctly.";
var summary = document.createElement("summary"); const summary = document.createElement("summary");
summary.textContent = "More details"; summary.textContent = "More details";
var data = document.createElement("pre"); const data = document.createElement("pre");
data.textContent = e.message; // e is an ErrorEvent data.textContent = e.message; // e is an ErrorEvent
var info = document.createElement("p"); const info = document.createElement("p");
info.textContent = "Open the developer tools of your browser for more information."; info.textContent = "Open the developer tools of your browser for more information.";
var details = document.createElement("details"); const details = document.createElement("details");
details.appendChild(summary); details.appendChild(summary);
details.appendChild(data); details.appendChild(data);
details.appendChild(info); details.appendChild(info);
message.parentNode.insertBefore(details, message.nextSibling); msg.parentNode.insertBefore(details, msg.nextSibling);
window.clearTimeout(loadingSlowTimeout); window.clearTimeout(loadingSlowTimeout);
displayReload(); displayReload();
}; };
window.addEventListener("error", window.g_LoungeErrorHandler); window.addEventListener("error", errorHandler);
window.g_TheLoungeRemoveLoading = () => {
delete window.g_TheLoungeRemoveLoading;
window.clearTimeout(loadingSlowTimeout);
window.removeEventListener("error", errorHandler);
document.getElementById("loading").remove();
};
// Trigger early service worker registration // Trigger early service worker registration
if ("serviceWorker" in navigator) { if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("service-worker.js"); navigator.serviceWorker.register("service-worker.js");
// Handler for messages coming from the service worker // Handler for messages coming from the service worker
var messageHandler = function ServiceWorkerMessageHandler(event) { const messageHandler = (event) => {
if (event.data.type === "fetch-error") { if (event.data.type === "fetch-error") {
window.g_LoungeErrorHandler({ errorHandler({
message: `Service worker failed to fetch an url: ${event.data.message}`, message: `Service worker failed to fetch an url: ${event.data.message}`,
}); });

View File

@ -10,7 +10,7 @@
// https://github.com/thelounge/thelounge/issues/2699 // https://github.com/thelounge/thelounge/issues/2699
// https://www.chromium.org/for-testers/bug-reporting-guidelines/uncaught-securityerror-failed-to-read-the-localstorage-property-from-window-access-is-denied-for-this-document // https://www.chromium.org/for-testers/bug-reporting-guidelines/uncaught-securityerror-failed-to-read-the-localstorage-property-from-window-access-is-denied-for-this-document
module.exports = { export default {
set(key, value) { set(key, value) {
try { try {
window.localStorage.setItem(key, value); window.localStorage.setItem(key, value);

View File

@ -2,7 +2,7 @@
// This is a thin wrapper around `window.location`, in order to contain the // This is a thin wrapper around `window.location`, in order to contain the
// side-effects. Do not add logic to it as it cannot be tested, only mocked. // side-effects. Do not add logic to it as it cannot be tested, only mocked.
module.exports = { export default {
reload() { reload() {
window.location.reload(); window.location.reload();
}, },

View File

@ -1,248 +0,0 @@
"use strict";
// vendor libraries
const $ = require("jquery");
// our libraries
const socket = require("./socket");
const {vueApp, findChannel} = require("./vue");
window.vueMounted = () => {
require("./socket-events");
const slideoutMenu = require("./slideout");
const contextMenuFactory = require("./contextMenuFactory");
const storage = require("./localStorage");
const utils = require("./utils");
require("./webpush");
require("./keybinds");
const sidebar = $("#sidebar, #footer");
const viewport = $("#viewport");
function storeSidebarVisibility(name, state) {
storage.set(name, state);
vueApp.$emit("resize");
}
// If sidebar overlay is visible and it is clicked, close the sidebar
$("#sidebar-overlay").on("click", () => {
slideoutMenu.toggle(false);
if ($(window).outerWidth() > utils.mobileViewportPixels) {
storeSidebarVisibility("thelounge.state.sidebar", false);
}
});
$("#windows").on("click", "button.lt", () => {
const isOpen = !slideoutMenu.isOpen();
slideoutMenu.toggle(isOpen);
if ($(window).outerWidth() > utils.mobileViewportPixels) {
storeSidebarVisibility("thelounge.state.sidebar", isOpen);
}
});
viewport.on("click", ".rt", function() {
const isOpen = !viewport.hasClass("userlist-open");
viewport.toggleClass("userlist-open", isOpen);
storeSidebarVisibility("thelounge.state.userlist", isOpen);
return false;
});
viewport.on("contextmenu", ".network .chan", function(e) {
return contextMenuFactory.createContextMenu($(this), e).show();
});
viewport.on("click contextmenu", ".user", function(e) {
// If user is selecting text, do not open context menu
// This primarily only targets mobile devices where selection is performed with touch
if (!window.getSelection().isCollapsed) {
return true;
}
return contextMenuFactory.createContextMenu($(this), e).show();
});
viewport.on("click", "#chat .menu", function(e) {
e.currentTarget = $(
`#sidebar .chan[data-id="${$(this)
.closest(".chan")
.attr("data-id")}"]`
)[0];
return contextMenuFactory.createContextMenu($(this), e).show();
});
if (navigator.platform.match(/(Mac|iPhone|iPod|iPad)/i)) {
$(document.body).addClass("is-apple");
}
viewport.on("click", ".inline-channel", function() {
const name = $(this).attr("data-chan");
const chan = utils.findCurrentNetworkChan(name);
if (chan) {
$(`#sidebar .chan[data-id="${chan.id}"]`).trigger("click");
}
socket.emit("input", {
target: vueApp.activeChannel.channel.id,
text: "/join " + name,
});
});
const openWindow = function openWindow(e, {pushState, replaceHistory} = {}) {
const self = $(this);
const target = self.attr("data-target");
if (!target) {
return;
}
// This is a rather gross hack to account for sources that are in the
// sidebar specifically. Needs to be done better when window management gets
// refactored.
const inSidebar = self.parents("#sidebar, #footer").length > 0;
const channel = inSidebar ? findChannel(Number(self.attr("data-id"))) : null;
if (vueApp.activeChannel) {
const {channel: lastChannel} = vueApp.activeChannel;
// If user clicks on the currently active channel, do nothing
if (channel && lastChannel === channel.channel) {
return;
}
if (lastChannel.messages.length > 0) {
lastChannel.firstUnread = lastChannel.messages[lastChannel.messages.length - 1].id;
}
if (lastChannel.messages.length > 100) {
lastChannel.messages.splice(0, lastChannel.messages.length - 100);
lastChannel.moreHistoryAvailable = true;
}
}
if (inSidebar) {
vueApp.activeChannel = channel;
if (channel) {
channel.channel.highlight = 0;
channel.channel.unread = 0;
}
socket.emit("open", channel ? channel.channel.id : null);
if ($(window).outerWidth() <= utils.mobileViewportPixels) {
slideoutMenu.toggle(false);
}
}
const lastActive = $("#windows > .active");
lastActive.removeClass("active");
const chan = $(target)
.addClass("active")
.trigger("show");
utils.synchronizeNotifiedState();
if (self.hasClass("chan")) {
vueApp.$nextTick(() => $("#chat-container").addClass("active"));
}
const chanChat = chan.find(".chat");
if (chanChat.length > 0 && channel.type !== "special") {
// On touch devices unfocus (blur) the input to correctly close the virtual keyboard
// An explicit blur is required, as the keyboard may open back up if the focus remains
// See https://github.com/thelounge/thelounge/issues/2257
$("#input").trigger("ontouchstart" in window ? "blur" : "focus");
}
if (channel && channel.channel.usersOutdated) {
channel.channel.usersOutdated = false;
socket.emit("names", {
target: channel.channel.id,
});
}
// Pushes states to history web API when clicking elements with a data-target attribute.
// States are very trivial and only contain a single `clickTarget` property which
// contains a CSS selector that targets elements which takes the user to a different view
// when clicked. The `popstate` event listener will trigger synthetic click events using that
// selector and thus take the user to a different view/state.
if (pushState === false) {
return false;
}
const state = {};
if (self.prop("id")) {
state.clickTarget = `#${self.prop("id")}`;
} else if (self.hasClass("chan")) {
state.clickTarget = `#sidebar .chan[data-id="${self.attr("data-id")}"]`;
} else {
state.clickTarget = `#footer button[data-target="${target}"]`;
}
if (history && history.pushState) {
if (replaceHistory && history.replaceState) {
history.replaceState(state, null, target);
} else {
history.pushState(state, null, target);
}
}
return false;
};
sidebar.on("click", ".chan, button", openWindow);
$("#help").on("click", "#view-changelog, #back-to-help", openWindow);
$("#changelog").on("click", "#back-to-help", openWindow);
sidebar.on("click", ".close", function() {
utils.closeChan($(this).closest(".chan"));
});
$(document).on("visibilitychange focus click", () => {
utils.synchronizeNotifiedState();
});
window.addEventListener("popstate", (e) => {
const {state} = e;
if (!state) {
return;
}
let {clickTarget} = state;
if (clickTarget) {
// This will be true when click target corresponds to opening a thumbnail,
// browsing to the previous/next thumbnail, or closing the image viewer.
const imageViewerRelated = clickTarget.includes(".toggle-thumbnail");
// If the click target is not related to the image viewer but the viewer
// is currently opened, we need to close it.
if (!imageViewerRelated && $("#image-viewer").hasClass("opened")) {
clickTarget += ", #image-viewer";
}
// Emit the click to the target, while making sure it is not going to be
// added to the state again.
$(clickTarget).trigger("click", {
pushState: false,
});
}
});
// Only start opening socket.io connection after all events have been registered
socket.open();
};

View File

@ -1,278 +0,0 @@
"use strict";
const $ = require("jquery");
const storage = require("./localStorage");
const socket = require("./socket");
const {vueApp} = require("./vue");
require("../js/autocompletion");
const $windows = $("#windows");
const $settings = $("#settings");
const $theme = $("#theme");
const $userStyles = $("#user-specified-css");
const noCSSparamReg = /[?&]nocss/;
// Not yet available at this point but used in various functionaly.
// Will be assigned when `initialize` is called.
let $syncWarningOverride;
let $syncWarningBase;
let $forceSyncButton;
let $warningUnsupported;
let $warningBlocked;
// Default settings
const settings = vueApp.settings;
const noSync = ["syncSettings"];
// alwaysSync is reserved for settings that should be synced
// to the server regardless of the clients sync setting.
const alwaysSync = ["highlights"];
const defaultThemeColor = document.querySelector('meta[name="theme-color"]').content;
// Process usersettings from localstorage.
let userSettings = JSON.parse(storage.get("settings")) || false;
if (!userSettings) {
// Enable sync by default if there are no user defined settings.
settings.syncSettings = true;
} else {
for (const key in settings) {
// Older The Lounge versions converted highlights to an array, turn it back into a string
if (key === "highlights" && typeof userSettings[key] === "object") {
userSettings[key] = userSettings[key].join(", ");
}
// Make sure the setting in local storage has the same type that the code expects
if (
typeof userSettings[key] !== "undefined" &&
typeof settings[key] === typeof userSettings[key]
) {
settings[key] = userSettings[key];
}
}
}
// Apply custom CSS and themes on page load
// Done here and not on init because on slower devices and connections
// it can take up to several seconds before init is called.
if (typeof userSettings.userStyles === "string" && !noCSSparamReg.test(window.location.search)) {
$userStyles.html(userSettings.userStyles);
}
if (
typeof userSettings.theme === "string" &&
$theme.attr("href") !== `themes/${userSettings.theme}.css`
) {
$theme.attr("href", `themes/${userSettings.theme}.css`);
}
userSettings = null;
module.exports = {
alwaysSync,
noSync,
initialized: false,
settings,
syncAllSettings,
processSetting,
initialize,
};
// Updates the checkbox and warning in settings.
// When notifications are not supported, this is never called (because
// checkbox state can not be changed).
function updateDesktopNotificationStatus() {
if (Notification.permission === "denied") {
$warningBlocked.show();
} else {
$warningBlocked.hide();
}
}
function applySetting(name, value) {
if (name === "syncSettings" && value) {
$syncWarningOverride.hide();
$forceSyncButton.hide();
} else if (name === "theme") {
const themeUrl = `themes/${value}.css`;
if ($theme.attr("href") !== themeUrl) {
$theme.attr("href", themeUrl);
const newTheme = $settings.find("#theme-select option[value='" + value + "']");
let themeColor = defaultThemeColor;
if (newTheme.length > 0 && newTheme[0].dataset.themeColor) {
themeColor = newTheme[0].dataset.themeColor;
}
document.querySelector('meta[name="theme-color"]').content = themeColor;
}
} else if (name === "userStyles" && !noCSSparamReg.test(window.location.search)) {
$userStyles.html(value);
} else if (name === "desktopNotifications") {
if ("Notification" in window && value && Notification.permission !== "granted") {
Notification.requestPermission(updateDesktopNotificationStatus);
} else if (!value) {
$warningBlocked.hide();
}
} else if (name === "advanced") {
$("#settings [data-advanced]").toggle(settings[name]);
}
}
function settingSetEmit(name, value) {
socket.emit("setting:set", {name, value});
}
// When sync is `true` the setting will also be send to the backend for syncing.
function updateSetting(name, value, sync) {
const currentOption = settings[name];
// Only update and process when the setting is actually changed.
if (currentOption !== value) {
settings[name] = value;
storage.set("settings", JSON.stringify(settings));
applySetting(name, value);
// Sync is checked, request settings from server.
if (name === "syncSettings" && value) {
socket.emit("setting:get");
$syncWarningOverride.hide();
$syncWarningBase.hide();
$forceSyncButton.hide();
} else if (name === "syncSettings") {
$syncWarningOverride.show();
$forceSyncButton.show();
}
if (settings.syncSettings && !noSync.includes(name) && sync) {
settingSetEmit(name, value);
} else if (alwaysSync.includes(name) && sync) {
settingSetEmit(name, value);
}
}
}
function syncAllSettings(force = false) {
// Sync all settings if sync is enabled or force is true.
if (settings.syncSettings || force) {
for (const name in settings) {
if (!noSync.includes(name)) {
settingSetEmit(name, settings[name]);
} else if (alwaysSync.includes(name)) {
settingSetEmit(name, settings[name]);
}
}
$syncWarningOverride.hide();
$syncWarningBase.hide();
$forceSyncButton.hide();
} else {
$syncWarningOverride.hide();
$forceSyncButton.hide();
$syncWarningBase.show();
}
}
// If `save` is set to true it will pass the setting to `updateSetting()` processSetting
function processSetting(name, value, save) {
if (name === "userStyles") {
$settings.find("#user-specified-css-input").val(value);
} else if (name === "highlights") {
$settings.find(`input[name=${name}]`).val(value);
} else if (name === "nickPostfix") {
$settings.find(`input[name=${name}]`).val(value);
} else if (name === "statusMessages") {
$settings.find(`input[name=${name}][value=${value}]`).prop("checked", true);
} else if (name === "theme") {
$settings.find("#theme-select").val(value);
} else if (typeof value === "boolean") {
$settings.find(`input[name=${name}]`).prop("checked", value);
}
// No need to also call processSetting when `save` is true.
// updateSetting does take care of that.
if (save) {
// Sync is false as applySetting is never called as the result of a user changing the setting.
updateSetting(name, value, false);
} else {
applySetting(name, value);
}
}
function initialize() {
$warningBlocked = $settings.find("#warnBlockedDesktopNotifications");
$warningUnsupported = $settings.find("#warnUnsupportedDesktopNotifications");
$syncWarningOverride = $settings.find(".sync-warning-override");
$syncWarningBase = $settings.find(".sync-warning-base");
$forceSyncButton = $settings.find(".force-sync-button");
$warningBlocked.hide();
module.exports.initialized = true;
// Settings have now entirely updated, apply settings to the client.
for (const name in settings) {
processSetting(name, settings[name], false);
}
// If browser does not support notifications
// display proper message in settings.
if ("Notification" in window) {
$warningUnsupported.hide();
$windows.on("show", "#settings", updateDesktopNotificationStatus);
} else {
$warningUnsupported.show();
}
$settings.on("change", "input, select, textarea", function(e) {
// We only want to trigger on human triggered changes.
if (e.originalEvent) {
const $self = $(this);
const type = $self.prop("type");
const name = $self.prop("name");
if (type === "radio") {
if ($self.prop("checked")) {
updateSetting(name, $self.val(), true);
}
} else if (type === "checkbox") {
updateSetting(name, $self.prop("checked"), true);
settings[name] = $self.prop("checked");
} else if (type !== "password") {
updateSetting(name, $self.val(), true);
}
}
});
$settings.find("#forceSync").on("click", () => {
syncAllSettings(true);
});
// Local init is done, let's sync
// We always ask for synced settings even if it is disabled.
// Settings can be mandatory to sync and it is used to determine sync base state.
socket.emit("setting:get");
// Protocol handler
const defaultClientButton = $("#make-default-client");
if (window.navigator.registerProtocolHandler) {
defaultClientButton.on("click", function() {
const uri = document.location.origin + document.location.pathname + "?uri=%s";
window.navigator.registerProtocolHandler("irc", uri, "The Lounge");
window.navigator.registerProtocolHandler("ircs", uri, "The Lounge");
return false;
});
$("#native-app").prop("hidden", false);
} else {
defaultClientButton.hide();
}
}

View File

@ -1,134 +0,0 @@
"use strict";
const $ = require("jquery");
const Mousetrap = require("mousetrap");
const templates = require("../views");
/* Image viewer */
const imageViewer = $("#image-viewer");
$("#windows").on("click", ".toggle-thumbnail", function(event, data = {}) {
const link = $(this);
// Passing `data`, specifically `data.pushState`, to not add the action to the
// history state if back or forward buttons were pressed.
openImageViewer(link, data);
// Prevent the link to open a new page since we're opening the image viewer,
// but keep it a link to allow for Ctrl/Cmd+click.
// By binding this event on #chat we prevent input gaining focus after clicking.
return false;
});
imageViewer.on("click", function(event, data = {}) {
// Passing `data`, specifically `data.pushState`, to not add the action to the
// history state if back or forward buttons were pressed.
closeImageViewer(data);
});
Mousetrap.bind("esc", () => closeImageViewer());
Mousetrap.bind(["left", "right"], (e, key) => {
if (imageViewer.hasClass("opened")) {
const direction = key === "left" ? "previous" : "next";
imageViewer.find(`.${direction}-image-btn`).trigger("click");
}
});
function openImageViewer(link, {pushState = true} = {}) {
$(".previous-image").removeClass("previous-image");
$(".next-image").removeClass("next-image");
// The next two blocks figure out what are the previous/next images. We first
// look within the same message, as there can be multiple thumbnails per
// message, and if not, we look at previous/next messages and take the
// last/first thumbnail available.
// Only expanded thumbnails are being cycled through.
// Previous image
let previousImage = link
.closest(".preview")
.prev(".preview")
.find(".toggle-content .toggle-thumbnail")
.last();
if (!previousImage.length) {
previousImage = link
.closest(".msg")
.prevAll()
.find(".toggle-content .toggle-thumbnail")
.last();
}
previousImage.addClass("previous-image");
// Next image
let nextImage = link
.closest(".preview")
.next(".preview")
.find(".toggle-content .toggle-thumbnail")
.first();
if (!nextImage.length) {
nextImage = link
.closest(".msg")
.nextAll()
.find(".toggle-content .toggle-thumbnail")
.first();
}
nextImage.addClass("next-image");
imageViewer.html(
templates.image_viewer({
image: link.find("img").prop("src"),
link: link.prop("href"),
type: link.parent().hasClass("toggle-type-link") ? "link" : "image",
hasPreviousImage: previousImage.length > 0,
hasNextImage: nextImage.length > 0,
})
);
// Turn off transitionend listener before opening the viewer,
// which caused image viewer to become empty in rare cases
imageViewer.off("transitionend").addClass("opened");
// History management
if (pushState) {
let clickTarget = "";
// Images can be in a message (channel URL previews) or not (window URL
// preview, e.g. changelog). This is sub-optimal and needs improvement to
// make image preview more generic and not specific for channel previews.
if (link.closest(".msg").length > 0) {
clickTarget = `#${link.closest(".msg").prop("id")} `;
}
clickTarget += `a.toggle-thumbnail[href="${link.prop("href")}"] img`;
history.pushState({clickTarget}, null, null);
}
}
imageViewer.on("click", ".previous-image-btn", function() {
$(".previous-image").trigger("click");
return false;
});
imageViewer.on("click", ".next-image-btn", function() {
$(".next-image").trigger("click");
return false;
});
function closeImageViewer({pushState = true} = {}) {
imageViewer.removeClass("opened").one("transitionend", function() {
imageViewer.empty();
});
// History management
if (pushState) {
const clickTarget =
"#sidebar " + `.chan[data-id="${$("#sidebar .chan.active").attr("data-id")}"]`;
history.pushState({clickTarget}, null, null);
}
}

144
client/js/router.js Normal file
View File

@ -0,0 +1,144 @@
"use strict";
import Vue from "vue";
import VueRouter from "vue-router";
Vue.use(VueRouter);
import SignIn from "../components/Windows/SignIn.vue";
import Connect from "../components/Windows/Connect.vue";
import Settings from "../components/Windows/Settings.vue";
import Help from "../components/Windows/Help.vue";
import Changelog from "../components/Windows/Changelog.vue";
import NetworkEdit from "../components/Windows/NetworkEdit.vue";
import RoutedChat from "../components/RoutedChat.vue";
import constants from "./constants";
import store from "./store";
const router = new VueRouter({
routes: [
{
name: "SignIn",
path: "/sign-in",
component: SignIn,
beforeEnter(to, from, next) {
// Prevent navigating to sign-in when already signed in
if (store.state.appLoaded) {
next(false);
return;
}
next();
},
},
],
});
router.beforeEach((to, from, next) => {
// Disallow navigating to non-existing routes
if (store.state.appLoaded && !to.matched.length) {
next(false);
return;
}
// Disallow navigating to invalid channels
if (to.name === "RoutedChat" && !store.getters.findChannel(Number(to.params.id))) {
next(false);
return;
}
// Handle closing image viewer with the browser back button
if (!router.app.$refs.app) {
next();
return;
}
const imageViewer = router.app.$root.$refs.app.$refs.imageViewer;
if (imageViewer && imageViewer.link) {
imageViewer.closeViewer();
next(false);
return;
}
next();
});
router.afterEach((to) => {
if (store.state.appLoaded) {
if (window.innerWidth <= constants.mobileViewportPixels) {
store.commit("sidebarOpen", false);
}
}
if (store.state.activeChannel) {
const channel = store.state.activeChannel.channel;
if (to.name !== "RoutedChat") {
store.commit("activeChannel", null);
}
// When switching out of a channel, mark everything as read
if (channel.messages.length > 0) {
channel.firstUnread = channel.messages[channel.messages.length - 1].id;
}
if (channel.messages.length > 100) {
channel.messages.splice(0, channel.messages.length - 100);
channel.moreHistoryAvailable = true;
}
}
});
function initialize() {
router.addRoutes([
{
name: "Connect",
path: "/connect",
component: Connect,
props: (route) => ({queryParams: route.query}),
},
{
name: "Settings",
path: "/settings",
component: Settings,
},
{
name: "Help",
path: "/help",
component: Help,
},
{
name: "Changelog",
path: "/changelog",
component: Changelog,
},
{
name: "NetworkEdit",
path: "/edit-network/:uuid",
component: NetworkEdit,
},
{
name: "RoutedChat",
path: "/chan-:id",
component: RoutedChat,
},
]);
}
function navigate(routeName, params = {}) {
if (router.currentRoute.name) {
router.push({name: routeName, params}).catch(() => {});
} else {
// If current route is null, replace the history entry
// This prevents invalid entries from lingering in history,
// and then the route guard preventing proper navigation
router.replace({name: routeName, params}).catch(() => {});
}
}
function switchToChannel(channel) {
return navigate("RoutedChat", {id: channel.id});
}
export {initialize, router, navigate, switchToChannel};

115
client/js/settings.js Normal file
View File

@ -0,0 +1,115 @@
import socket from "./socket";
const defaultSettingConfig = {
apply() {},
default: null,
sync: null,
};
export const config = normalizeConfig({
syncSettings: {
default: true,
sync: "never",
apply(store, value) {
if (value) {
socket.emit("setting:get");
}
},
},
advanced: {
default: false,
},
autocomplete: {
default: true,
},
nickPostfix: {
default: "",
},
coloredNicks: {
default: true,
},
desktopNotifications: {
default: false,
apply(store, value) {
store.commit("refreshDesktopNotificationState", null, {root: true});
if ("Notification" in window && value && Notification.permission !== "granted") {
Notification.requestPermission(() =>
store.commit("refreshDesktopNotificationState", null, {root: true})
);
}
},
},
highlights: {
default: "",
sync: "always",
},
links: {
default: true,
},
motd: {
default: true,
},
notification: {
default: true,
},
notifyAllMessages: {
default: false,
},
showSeconds: {
default: false,
},
statusMessages: {
default: "condensed",
},
theme: {
default: document.getElementById("theme").dataset.serverTheme,
apply(store, value) {
const themeEl = document.getElementById("theme");
const themeUrl = `themes/${value}.css`;
if (themeEl.attributes.href.value === themeUrl) {
return;
}
themeEl.attributes.href.value = themeUrl;
const newTheme = store.state.serverConfiguration.themes.filter(
(theme) => theme.name === value
)[0];
const themeColor =
newTheme.themeColor || document.querySelector('meta[name="theme-color"]').content;
document.querySelector('meta[name="theme-color"]').content = themeColor;
},
},
media: {
default: true,
},
userStyles: {
default: "",
apply(store, value) {
if (!/[?&]nocss/.test(window.location.search)) {
document.getElementById("user-specified-css").innerHTML = value;
}
},
},
});
export function createState() {
const state = {};
for (const settingName in config) {
state[settingName] = config[settingName].default;
}
return state;
}
function normalizeConfig(obj) {
const newConfig = {};
for (const settingName in obj) {
newConfig[settingName] = {...defaultSettingConfig, ...obj[settingName]};
}
return newConfig;
}

View File

@ -1,112 +0,0 @@
"use strict";
const viewport = document.getElementById("viewport");
const menu = document.getElementById("sidebar");
const sidebarOverlay = document.getElementById("sidebar-overlay");
let touchStartPos = null;
let touchCurPos = null;
let touchStartTime = 0;
let menuWidth = 0;
let menuIsOpen = false;
let menuIsMoving = false;
let menuIsAbsolute = false;
class SlideoutMenu {
static enable() {
document.body.addEventListener("touchstart", onTouchStart, {passive: true});
}
static toggle(state) {
menuIsOpen = state;
viewport.classList.toggle("menu-open", state);
}
static isOpen() {
return menuIsOpen;
}
}
function onTouchStart(e) {
touchStartPos = touchCurPos = e.touches.item(0);
if (e.touches.length !== 1) {
onTouchEnd();
return;
}
const styles = window.getComputedStyle(menu);
menuWidth = parseFloat(styles.width);
menuIsAbsolute = styles.position === "absolute";
if (!menuIsOpen || touchStartPos.screenX > menuWidth) {
touchStartTime = Date.now();
document.body.addEventListener("touchmove", onTouchMove, {passive: true});
document.body.addEventListener("touchend", onTouchEnd, {passive: true});
}
}
function onTouchMove(e) {
const touch = (touchCurPos = e.touches.item(0));
let distX = touch.screenX - touchStartPos.screenX;
const distY = touch.screenY - touchStartPos.screenY;
if (!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) {
onTouchEnd();
return;
}
const devicePixelRatio = window.devicePixelRatio || 2;
if (Math.abs(distX) > devicePixelRatio) {
viewport.classList.toggle("menu-dragging", true);
menuIsMoving = true;
}
}
// Do not animate the menu on desktop view
if (!menuIsAbsolute) {
return;
}
if (menuIsOpen) {
distX += menuWidth;
}
if (distX > menuWidth) {
distX = menuWidth;
} else if (distX < 0) {
distX = 0;
}
menu.style.transform = "translate3d(" + distX + "px, 0, 0)";
sidebarOverlay.style.opacity = distX / menuWidth;
}
function onTouchEnd() {
const diff = touchCurPos.screenX - touchStartPos.screenX;
const absDiff = Math.abs(diff);
if (absDiff > menuWidth / 2 || (Date.now() - touchStartTime < 180 && absDiff > 50)) {
SlideoutMenu.toggle(diff > 0);
}
document.body.removeEventListener("touchmove", onTouchMove);
document.body.removeEventListener("touchend", onTouchEnd);
viewport.classList.toggle("menu-dragging", false);
menu.style.transform = null;
sidebarOverlay.style.opacity = null;
touchStartPos = null;
touchCurPos = null;
touchStartTime = 0;
menuIsMoving = false;
}
module.exports = SlideoutMenu;

View File

@ -1,112 +1,100 @@
"use strict"; "use strict";
const $ = require("jquery"); import socket from "../socket";
const socket = require("../socket"); import storage from "../localStorage";
const storage = require("../localStorage"); import {router, navigate} from "../router";
const utils = require("../utils"); import store from "../store";
const templates = require("../../views"); import location from "../location";
const {vueApp} = require("../vue"); let lastServerHash = null;
socket.on("auth", function(data) { socket.on("auth:success", function() {
store.commit("currentUserVisibleError", "Loading messages…");
updateLoadingMessage();
});
socket.on("auth:failed", function() {
storage.remove("token");
if (store.state.appLoaded) {
return reloadPage("Authentication failed, reloading…");
}
showSignIn();
});
socket.on("auth:start", function(serverHash) {
// If we reconnected and serverHash differs, that means the server restarted // If we reconnected and serverHash differs, that means the server restarted
// And we will reload the page to grab the latest version // And we will reload the page to grab the latest version
if (utils.serverHash > -1 && data.serverHash > -1 && data.serverHash !== utils.serverHash) { if (lastServerHash && serverHash !== lastServerHash) {
socket.disconnect(); return reloadPage("Server restarted, reloading…");
vueApp.isConnected = false;
vueApp.currentUserVisibleError = "Server restarted, reloading…";
location.reload(true);
return;
} }
const login = $("#sign-in"); lastServerHash = serverHash;
if (data.serverHash > -1) {
utils.serverHash = data.serverHash;
login.html(templates.windows.sign_in());
utils.togglePasswordField("#sign-in .reveal-password");
login.find("form").on("submit", function() {
const form = $(this);
form.find(".btn").prop("disabled", true);
const values = {};
$.each(form.serializeArray(), function(i, obj) {
values[obj.name] = obj.value;
});
storage.set("user", values.user);
socket.emit("auth", values);
return false;
});
} else {
login.find(".btn").prop("disabled", false);
}
let token;
const user = storage.get("user"); const user = storage.get("user");
const token = storage.get("token");
const doFastAuth = user && token;
if (!data.success) { // If we reconnect and no longer have a stored token, reload the page
if (login.length === 0) { if (store.state.appLoaded && !doFastAuth) {
socket.disconnect(); return reloadPage("Authentication failed, reloading…");
vueApp.isConnected = false; }
vueApp.currentUserVisibleError = "Authentication failed, reloading…";
location.reload();
return;
}
storage.remove("token"); // If we have user and token stored, perform auth without showing sign-in first
if (doFastAuth) {
store.commit("currentUserVisibleError", "Authorizing…");
updateLoadingMessage();
const error = login.find(".error"); let lastMessage = -1;
error
.show()
.closest("form")
.one("submit", function() {
error.hide();
});
} else if (user) {
token = storage.get("token");
if (token) { for (const network of store.state.networks) {
vueApp.currentUserVisibleError = "Authorizing…"; for (const chan of network.channels) {
$("#loading-page-message").text(vueApp.currentUserVisibleError); if (chan.messages.length > 0) {
const id = chan.messages[chan.messages.length - 1].id;
let lastMessage = -1; if (lastMessage < id) {
lastMessage = id;
for (const network of vueApp.networks) {
for (const chan of network.channels) {
if (chan.messages.length > 0) {
const id = chan.messages[chan.messages.length - 1].id;
if (lastMessage < id) {
lastMessage = id;
}
} }
} }
} }
const openChannel = (vueApp.activeChannel && vueApp.activeChannel.channel.id) || null;
socket.emit("auth", {user, token, lastMessage, openChannel});
} }
}
if (user) { const openChannel =
login.find("input[name='user']").val(user); (store.state.activeChannel && store.state.activeChannel.channel.id) || null;
}
if (token) { socket.emit("auth:perform", {
return; user,
} token,
lastMessage,
$("#loading").remove(); openChannel,
$("#footer") hasConfig: store.state.serverConfiguration !== null,
.find(".sign-in")
.trigger("click", {
pushState: false,
}); });
} else {
showSignIn();
}
}); });
function showSignIn() {
// TODO: this flashes grey background because it takes a little time for vue to mount signin
if (window.g_TheLoungeRemoveLoading) {
window.g_TheLoungeRemoveLoading();
}
if (router.currentRoute.name !== "SignIn") {
navigate("SignIn");
}
}
function reloadPage(message) {
socket.disconnect();
store.commit("currentUserVisibleError", message);
location.reload(true);
}
function updateLoadingMessage() {
const loading = document.getElementById("loading-page-message");
if (loading) {
loading.textContent = store.state.currentUserVisibleError;
}
}

View File

@ -1,31 +0,0 @@
"use strict";
const $ = require("jquery");
const socket = require("../socket");
socket.on("change-password", function(data) {
const passwordForm = $("#change-password");
if (data.error || data.success) {
const message = data.success ? data.success : data.error;
const feedback = passwordForm.find(".feedback");
if (data.success) {
feedback.addClass("success").removeClass("error");
} else {
feedback.addClass("error").removeClass("success");
}
feedback.text(message).show();
feedback.closest("form").one("submit", function() {
feedback.hide();
});
}
passwordForm
.find("input")
.val("")
.end()
.find(".btn")
.prop("disabled", false);
});

View File

@ -1,30 +1,12 @@
"use strict"; "use strict";
const $ = require("jquery"); import socket from "../socket";
const socket = require("../socket"); import store from "../store";
const templates = require("../../views");
// Requests version information if it hasn't been retrieved before (or if it has
// been removed from the page, i.e. when clicking on "Check now". Displays a
// loading state until received.
function requestIfNeeded() {
if ($("#version-checker").is(":empty")) {
renderVersionChecker({status: "loading"});
socket.emit("changelog");
}
}
socket.on("changelog", function(data) { socket.on("changelog", function(data) {
// 1. Release notes window for the current version store.commit("versionData", data);
$("#changelog").html(templates.windows.changelog(data.current)); store.commit("versionDataExpired", false);
const links = $("#changelog .changelog-text a");
// Make sure all links will open a new tab instead of exiting the application
links.prop("target", "_blank");
// Add required metadata to image links, to support built-in image viewer
links.has("img").addClass("toggle-thumbnail");
// 2. Version checker visible in Help window
let status; let status;
if (data.latest) { if (data.latest) {
@ -37,31 +19,11 @@ socket.on("changelog", function(data) {
status = "error"; status = "error";
} }
renderVersionChecker({ store.commit("versionStatus", status);
latest: data.latest,
status,
});
// When there is a button to refresh the checker available, display it when // When there is a button to refresh the checker available, display it when
// data is expired. Before that, server would return same information anyway. // data is expired. Before that, server would return same information anyway.
if (data.expiresAt) { if (data.expiresAt) {
setTimeout(() => $("#version-checker #check-now").show(), data.expiresAt - Date.now()); setTimeout(() => store.commit("versionDataExpired", true), data.expiresAt - Date.now());
} }
}); });
$("#help, #changelog").on("show", requestIfNeeded);
// When clicking the "Check now" button, remove current checker information and
// request a new one. Loading will be displayed in the meantime.
$("#help").on("click", "#check-now", () => {
$("#version-checker").empty();
requestIfNeeded();
});
// Given a status and latest release information, update the version checker
// (CSS class and content)
function renderVersionChecker({status, latest}) {
$("#version-checker")
.prop("class", status)
.html(templates.version_checker({latest, status}));
}

View File

@ -1,5 +1,5 @@
const constants = require("../constants"); import constants from "../constants";
const socket = require("../socket"); import socket from "../socket";
socket.on("commands", function(commands) { socket.on("commands", function(commands) {
if (commands) { if (commands) {

View File

@ -1,229 +1,35 @@
"use strict"; "use strict";
const $ = require("jquery"); import socket from "../socket";
const socket = require("../socket"); import upload from "../upload";
const templates = require("../../views"); import store from "../store";
const options = require("../options");
const webpush = require("../webpush");
const connect = $("#connect");
const utils = require("../utils");
const upload = require("../upload");
const {vueApp} = require("../vue");
window.addEventListener("beforeinstallprompt", (installPromptEvent) => { socket.once("configuration", function(data) {
$("#webapp-install-button") store.commit("serverConfiguration", data);
.on("click", function() {
if (installPromptEvent && installPromptEvent.prompt) {
installPromptEvent.prompt();
}
$(this).prop("hidden", true); // 'theme' setting depends on serverConfiguration.themes so
}) // settings cannot be applied before this point
.prop("hidden", false); store.dispatch("settings/applyAll");
socket.emit("setting:get");
$("#native-app").prop("hidden", false);
});
socket.on("configuration", function(data) {
vueApp.isFileUploadEnabled = data.fileUpload;
if (options.initialized) {
// Likely a reconnect, request sync for possibly missed settings.
socket.emit("setting:get");
return;
}
$("#settings").html(templates.windows.settings(data));
$("#help").html(templates.windows.help(data));
$("#changelog").html(templates.windows.changelog());
$("#settings").on("show", () => {
$("#session-list").html("<p>Loading…</p>");
socket.emit("sessions:get");
});
$("#play").on("click", () => {
const pop = new Audio();
pop.src = "audio/pop.wav";
pop.play();
});
if (data.fileUpload) { if (data.fileUpload) {
upload.initialize(data.fileUploadMaxFileSize); upload.initialize();
} }
utils.togglePasswordField("#change-password .reveal-password");
options.initialize();
webpush.initialize();
// If localStorage contains a theme that does not exist on this server, switch // If localStorage contains a theme that does not exist on this server, switch
// back to its default theme. // back to its default theme.
const currentTheme = data.themes.find((t) => t.name === options.settings.theme); const currentTheme = data.themes.find((t) => t.name === store.state.settings.theme);
if (currentTheme === undefined) { if (currentTheme === undefined) {
options.processSetting("theme", data.defaultTheme, true); store.commit("settings/update", {name: "theme", value: data.defaultTheme, sync: true});
} else if (currentTheme.themeColor) { } else if (currentTheme.themeColor) {
document.querySelector('meta[name="theme-color"]').content = currentTheme.themeColor; document.querySelector('meta[name="theme-color"]').content = currentTheme.themeColor;
} }
function handleFormSubmit() { if (document.body.classList.contains("public")) {
const form = $(this); window.addEventListener(
const event = form.data("event"); "beforeunload",
() => "Are you sure you want to navigate away from this page?"
form.find(".btn").prop("disabled", true); );
const values = {};
$.each(form.serializeArray(), function(i, obj) {
if (obj.value !== "") {
values[obj.name] = obj.value;
}
});
socket.emit(event, values);
return false;
}
$("#change-password form").on("submit", handleFormSubmit);
connect.on("submit", "form", handleFormSubmit);
connect.on("show", function() {
connect
.html(templates.windows.connect(data))
.find("#connect\\:nick")
.on("focusin", function() {
// Need to set the first "lastvalue", so it can be used in the below function
const nick = $(this);
nick.data("lastvalue", nick.val());
})
.on("input", function() {
const nick = $(this).val();
const usernameInput = connect.find(".username");
// Because this gets called /after/ it has already changed, we need use the previous value
const lastValue = $(this).data("lastvalue");
// They were the same before the change, so update the username field
if (usernameInput.val() === lastValue) {
usernameInput.val(nick);
}
// Store the "previous" value, for next time
$(this).data("lastvalue", nick);
});
utils.togglePasswordField("#connect .reveal-password");
});
if ("URLSearchParams" in window) {
const params = new URLSearchParams(document.location.search);
if (params.has("uri")) {
parseIrcUri(params.get("uri") + location.hash, data);
} else if ($(document.body).hasClass("public")) {
parseOverrideParams(params, data);
}
} }
}); });
function parseIrcUri(stringUri, defaults) {
const data = Object.assign({}, defaults.defaults);
try {
// https://tools.ietf.org/html/draft-butcher-irc-url-04
const uri = new URL(stringUri);
// Replace protocol with a "special protocol" (that's what it's called in WHATWG spec)
// So that the uri can be properly parsed
if (uri.protocol === "irc:") {
uri.protocol = "http:";
if (!uri.port) {
uri.port = 6667;
}
data.tls = false;
} else if (uri.protocol === "ircs:") {
uri.protocol = "https:";
if (!uri.port) {
uri.port = 6697;
}
data.tls = true;
} else {
return;
}
data.host = data.name = uri.hostname;
data.port = uri.port;
data.username = window.decodeURIComponent(uri.username) || data.username;
data.password = window.decodeURIComponent(uri.password) || data.password;
let channel = (uri.pathname + uri.hash).substr(1);
const index = channel.indexOf(",");
if (index > -1) {
channel = channel.substring(0, index);
}
data.join = channel;
// TODO: Need to show connect window with uri params without overriding defaults
defaults.defaults = data;
$('button[data-target="#connect"]').trigger("click");
} catch (e) {
// do nothing on invalid uri
}
}
function parseOverrideParams(params, data) {
for (let [key, value] of params) {
// Support `channels` as a compatibility alias with other clients
if (key === "channels") {
key = "join";
}
if (!Object.prototype.hasOwnProperty.call(data.defaults, key)) {
continue;
}
// When the network is locked, URL overrides should not affect disabled fields
if (data.lockNetwork && ["host", "port", "tls", "rejectUnauthorized"].includes(key)) {
continue;
}
// When the network is not displayed, its name in the UI is not customizable
if (!data.displayNetwork && key === "name") {
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 data.defaults[key]) {
case "boolean":
data.defaults[key] = value === "1" || value === "true";
break;
case "number":
data.defaults[key] = Number(value);
break;
case "string":
data.defaults[key] = String(value);
break;
}
}
}

View File

@ -0,0 +1,69 @@
import store from "../store";
import socket from "../socket";
socket.on("disconnect", handleDisconnect);
socket.on("connect_error", handleDisconnect);
socket.on("error", handleDisconnect);
socket.on("reconnecting", function(attempt) {
store.commit("currentUserVisibleError", `Reconnecting… (attempt ${attempt})`);
updateLoadingMessage();
});
socket.on("connecting", function() {
store.commit("currentUserVisibleError", "Connecting…");
updateLoadingMessage();
});
socket.on("connect", function() {
// Clear send buffer when reconnecting, socket.io would emit these
// immediately upon connection and it will have no effect, so we ensure
// nothing is sent to the server that might have happened.
socket.sendBuffer = [];
store.commit("currentUserVisibleError", "Finalizing connection…");
updateLoadingMessage();
});
function handleDisconnect(data) {
const message = data.message || data;
store.commit("isConnected", false);
if (!socket.io.reconnection()) {
store.commit(
"currentUserVisibleError",
`Disconnected from the server (${message}), The Lounge does not reconnect in public mode.`
);
updateLoadingMessage();
return;
}
store.commit("currentUserVisibleError", `Waiting to reconnect… (${message})`);
updateLoadingMessage();
// If the server shuts down, socket.io skips reconnection
// and we have to manually call connect to start the process
// However, do not reconnect if TL client manually closed the connection
if (socket.io.skipReconnect && message !== "io client disconnect") {
requestIdleCallback(() => socket.connect(), 2000);
}
}
function requestIdleCallback(callback, timeout) {
if (window.requestIdleCallback) {
// During an idle period the user agent will run idle callbacks in FIFO order
// until either the idle period ends or there are no more idle callbacks eligible to be run.
window.requestIdleCallback(callback, {timeout});
} else {
callback();
}
}
function updateLoadingMessage() {
const loading = document.getElementById("loading-page-message");
if (loading) {
loading.textContent = store.state.currentUserVisibleError;
}
}

View File

@ -1,25 +1,25 @@
"use strict"; "use strict";
require("./auth"); import "./connection";
require("./change_password"); import "./auth";
require("./commands"); import "./commands";
require("./init"); import "./init";
require("./join"); import "./join";
require("./more"); import "./more";
require("./msg"); import "./msg";
require("./msg_preview"); import "./msg_preview";
require("./msg_special"); import "./msg_special";
require("./names"); import "./names";
require("./network"); import "./network";
require("./nick"); import "./nick";
require("./open"); import "./open";
require("./part"); import "./part";
require("./quit"); import "./quit";
require("./sync_sort"); import "./sync_sort";
require("./topic"); import "./topic";
require("./users"); import "./users";
require("./sign_out"); import "./sign_out";
require("./sessions_list"); import "./sessions_list";
require("./configuration"); import "./configuration";
require("./changelog"); import "./changelog";
require("./setting"); import "./setting";

View File

@ -1,124 +1,70 @@
"use strict"; "use strict";
const $ = require("jquery"); import socket from "../socket";
const escape = require("css.escape"); import storage from "../localStorage";
const socket = require("../socket"); import {router, switchToChannel, navigate, initialize as routerInitialize} from "../router";
const webpush = require("../webpush"); import store from "../store";
const slideoutMenu = require("../slideout"); import parseIrcUri from "../helpers/parseIrcUri";
const sidebar = $("#sidebar");
const storage = require("../localStorage");
const utils = require("../utils");
const {vueApp, initChannel} = require("../vue");
socket.on("init", function(data) { socket.on("init", function(data) {
vueApp.currentUserVisibleError = "Rendering…"; store.commit("networks", mergeNetworkData(data.networks));
$("#loading-page-message").text(vueApp.currentUserVisibleError); store.commit("isConnected", true);
store.commit("currentUserVisibleError", null);
const previousActive = vueApp.activeChannel && vueApp.activeChannel.channel.id; if (data.token) {
storage.set("token", data.token);
vueApp.networks = mergeNetworkData(data.networks);
vueApp.isConnected = true;
vueApp.currentUserVisibleError = null;
if (!vueApp.initialized) {
vueApp.initialized = true;
if (data.token) {
storage.set("token", data.token);
}
webpush.configurePushNotifications(data.pushSubscription, data.applicationServerKey);
slideoutMenu.enable();
const viewport = $("#viewport");
const viewportWidth = $(window).outerWidth();
let isUserlistOpen = storage.get("thelounge.state.userlist");
if (viewportWidth > utils.mobileViewportPixels) {
slideoutMenu.toggle(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";
}
viewport.toggleClass("userlist-open", isUserlistOpen === "true");
$(document.body).removeClass("signed-out");
$("#loading").remove();
$("#sign-in").remove();
if (window.g_LoungeErrorHandler) {
window.removeEventListener("error", window.g_LoungeErrorHandler);
window.g_LoungeErrorHandler = null;
}
} }
vueApp.$nextTick(() => openCorrectChannel(previousActive, data.active)); if (!store.state.appLoaded) {
// Routes are initialized after networks data is merged
// so the route guard for channels works correctly on page load
routerInitialize();
utils.confirmExit(); store.commit("appLoaded");
utils.synchronizeNotifiedState();
if (window.g_TheLoungeRemoveLoading) {
window.g_TheLoungeRemoveLoading();
}
// TODO: Review this code and make it better
if (!router.currentRoute.name || router.currentRoute.name === "SignIn") {
const channel = store.getters.findChannel(data.active);
if (channel) {
switchToChannel(channel.channel);
} else if (store.state.networks.length > 0) {
// Server is telling us to open a channel that does not exist
// For example, it can be unset if you first open the page after server start
switchToChannel(store.state.networks[0].channels[0]);
} else {
navigate("Connect");
}
}
if ("URLSearchParams" in window) {
handleQueryParams();
}
}
}); });
function openCorrectChannel(clientActive, serverActive) {
let target = $();
// Open last active channel
if (clientActive > 0) {
target = sidebar.find(`.chan[data-id="${clientActive}"]`);
}
// Open window provided in location.hash
if (target.length === 0 && window.location.hash) {
target = $(`[data-target="${escape(window.location.hash)}"]`).first();
}
// Open last active channel according to the server
if (serverActive > 0 && target.length === 0) {
target = sidebar.find(`.chan[data-id="${serverActive}"]`);
}
// Open first available channel
if (target.length === 0) {
target = sidebar.find(".chan").first();
}
// If target channel is found, open it
if (target.length > 0) {
target.trigger("click", {
replaceHistory: true,
});
return;
}
// Open the connect window
$("#footer .connect").trigger("click", {
pushState: false,
});
}
function mergeNetworkData(newNetworks) { function mergeNetworkData(newNetworks) {
const collapsedNetworks = new Set(JSON.parse(storage.get("thelounge.networks.collapsed"))); const collapsedNetworks = new Set(JSON.parse(storage.get("thelounge.networks.collapsed")));
for (let n = 0; n < newNetworks.length; n++) { for (let n = 0; n < newNetworks.length; n++) {
const network = newNetworks[n]; const network = newNetworks[n];
const currentNetwork = vueApp.networks.find((net) => net.uuid === network.uuid); const currentNetwork = store.getters.findNetwork(network.uuid);
// If this network is new, set some default variables and initalize channel variables // If this network is new, set some default variables and initalize channel variables
if (!currentNetwork) { if (!currentNetwork) {
network.isJoinChannelShown = false; network.isJoinChannelShown = false;
network.isCollapsed = collapsedNetworks.has(network.uuid); network.isCollapsed = collapsedNetworks.has(network.uuid);
network.channels.forEach(initChannel); network.channels.forEach(store.getters.initChannel);
continue; continue;
} }
// Merge received network object into existing network object on the client // Merge received network object into existing network object on the client
// so the object reference stays the same (e.g. for vueApp.currentChannel) // so the object reference stays the same (e.g. for currentChannel state)
for (const key in network) { for (const key in network) {
if (!Object.prototype.hasOwnProperty.call(network, key)) { if (!Object.prototype.hasOwnProperty.call(network, key)) {
continue; continue;
@ -148,13 +94,13 @@ function mergeChannelData(oldChannels, newChannels) {
// This is a new channel that was joined while client was disconnected, initialize it // This is a new channel that was joined while client was disconnected, initialize it
if (!currentChannel) { if (!currentChannel) {
initChannel(channel); store.getters.initChannel(channel);
continue; continue;
} }
// Merge received channel object into existing currentChannel // Merge received channel object into existing currentChannel
// so the object references are exactly the same (e.g. in vueApp.activeChannel) // so the object references are exactly the same (e.g. in store.state.activeChannel)
for (const key in channel) { for (const key in channel) {
if (!Object.prototype.hasOwnProperty.call(channel, key)) { if (!Object.prototype.hasOwnProperty.call(channel, key)) {
continue; continue;
@ -163,7 +109,10 @@ function mergeChannelData(oldChannels, newChannels) {
// Server sends an empty users array, client requests it whenever needed // Server sends an empty users array, client requests it whenever needed
if (key === "users") { if (key === "users") {
if (channel.type === "channel") { if (channel.type === "channel") {
if (vueApp.activeChannel && vueApp.activeChannel.channel === currentChannel) { if (
store.state.activeChannel &&
store.state.activeChannel.channel === currentChannel
) {
// For currently open channel, request the user list straight away // For currently open channel, request the user list straight away
socket.emit("names", { socket.emit("names", {
target: channel.id, target: channel.id,
@ -201,3 +150,28 @@ function mergeChannelData(oldChannels, newChannels) {
return newChannels; return newChannels;
} }
function handleQueryParams() {
const params = new URLSearchParams(document.location.search);
const cleanParams = () => {
// Remove query parameters from url without reloading the page
const cleanUri = window.location.origin + window.location.pathname + window.location.hash;
window.history.replaceState({}, document.title, cleanUri);
};
if (params.has("uri")) {
// Set default connection settings from IRC protocol links
const uri = params.get("uri");
const queryParams = parseIrcUri(uri);
cleanParams();
router.push({name: "Connect", query: queryParams});
} else if (document.body.classList.contains("public") && document.location.search) {
// Set default connection settings from url params
const queryParams = Object.fromEntries(params.entries());
cleanParams();
router.push({name: "Connect", query: queryParams});
}
}

View File

@ -1,22 +1,24 @@
"use strict"; "use strict";
const $ = require("jquery"); import socket from "../socket";
const socket = require("../socket"); import store from "../store";
const {vueApp, initChannel} = require("../vue"); import {switchToChannel} from "../router";
socket.on("join", function(data) { socket.on("join", function(data) {
initChannel(data.chan); store.getters.initChannel(data.chan);
vueApp.networks const network = store.getters.findNetwork(data.network);
.find((n) => n.uuid === data.network)
.channels.splice(data.index || -1, 0, data.chan); if (!network) {
return;
}
network.channels.splice(data.index || -1, 0, data.chan);
// Queries do not automatically focus, unless the user did a whois // Queries do not automatically focus, unless the user did a whois
if (data.chan.type === "query" && !data.shouldOpen) { if (data.chan.type === "query" && !data.shouldOpen) {
return; return;
} }
vueApp.$nextTick(() => { switchToChannel(store.getters.findChannel(data.chan.id).channel);
$(`#sidebar .chan[data-id="${data.chan.id}"]`).trigger("click");
});
}); });

View File

@ -1,10 +1,12 @@
"use strict"; "use strict";
const socket = require("../socket"); import Vue from "vue";
const {vueApp, findChannel} = require("../vue");
import socket from "../socket";
import store from "../store";
socket.on("more", function(data) { socket.on("more", function(data) {
const channel = findChannel(data.chan); const channel = store.getters.findChannel(data.chan);
if (!channel) { if (!channel) {
return; return;
@ -14,7 +16,7 @@ socket.on("more", function(data) {
data.totalMessages > channel.channel.messages.length + data.messages.length; data.totalMessages > channel.channel.messages.length + data.messages.length;
channel.channel.messages.unshift(...data.messages); channel.channel.messages.unshift(...data.messages);
vueApp.$nextTick(() => { Vue.nextTick(() => {
channel.channel.historyLoading = false; channel.channel.historyLoading = false;
}); });
}); });

View File

@ -1,12 +1,9 @@
"use strict"; "use strict";
const $ = require("jquery"); import socket from "../socket";
const socket = require("../socket"); import cleanIrcMessage from "../helpers/ircmessageparser/cleanIrcMessage";
const utils = require("../utils"); import store from "../store";
const options = require("../options"); import {switchToChannel} from "../router";
const cleanIrcMessage = require("../libs/handlebars/ircmessageparser/cleanIrcMessage");
const webpush = require("../webpush");
const {vueApp, findChannel} = require("../vue");
let pop; let pop;
@ -15,29 +12,30 @@ try {
pop.src = "audio/pop.wav"; pop.src = "audio/pop.wav";
} catch (e) { } catch (e) {
pop = { pop = {
play: $.noop, play() {},
}; };
} }
socket.on("msg", function(data) { socket.on("msg", function(data) {
const receivingChannel = findChannel(data.chan); const receivingChannel = store.getters.findChannel(data.chan);
if (!receivingChannel) { if (!receivingChannel) {
return; return;
} }
let channel = receivingChannel.channel; let channel = receivingChannel.channel;
const isActiveChannel = vueApp.activeChannel && vueApp.activeChannel.channel === channel; const isActiveChannel =
store.state.activeChannel && store.state.activeChannel.channel === channel;
// Display received notices and errors in currently active channel. // Display received notices and errors in currently active channel.
// Reloading the page will put them back into the lobby window. // Reloading the page will put them back into the lobby window.
// We only want to put errors/notices in active channel if they arrive on the same network // We only want to put errors/notices in active channel if they arrive on the same network
if ( if (
data.msg.showInActive && data.msg.showInActive &&
vueApp.activeChannel && store.state.activeChannel &&
vueApp.activeChannel.network === receivingChannel.network store.state.activeChannel.network === receivingChannel.network
) { ) {
channel = vueApp.activeChannel.channel; channel = store.state.activeChannel.channel;
if (data.chan === channel.id) { if (data.chan === channel.id) {
// If active channel is the intended channel for this message, // If active channel is the intended channel for this message,
@ -64,7 +62,7 @@ socket.on("msg", function(data) {
if (data.msg.self) { if (data.msg.self) {
channel.firstUnread = data.msg.id; channel.firstUnread = data.msg.id;
} else { } else {
notifyMessage(data.chan, channel, vueApp.activeChannel, data.msg); notifyMessage(data.chan, channel, store.state.activeChannel, data.msg);
} }
let messageLimit = 0; let messageLimit = 0;
@ -89,18 +87,12 @@ socket.on("msg", function(data) {
user.lastMessage = new Date(data.msg.time).getTime() || Date.now(); user.lastMessage = new Date(data.msg.time).getTime() || Date.now();
} }
} }
if (data.msg.self || data.msg.highlight) {
utils.synchronizeNotifiedState();
}
}); });
function notifyMessage(targetId, channel, activeChannel, msg) { function notifyMessage(targetId, channel, activeChannel, msg) {
const button = $("#sidebar .chan[data-id='" + targetId + "']"); if (msg.highlight || (store.state.settings.notifyAllMessages && msg.type === "message")) {
if (msg.highlight || (options.settings.notifyAllMessages && msg.type === "message")) {
if (!document.hasFocus() || !activeChannel || activeChannel.channel !== channel) { if (!document.hasFocus() || !activeChannel || activeChannel.channel !== channel) {
if (options.settings.notification) { if (store.state.settings.notification) {
try { try {
pop.play(); pop.play();
} catch (exception) { } catch (exception) {
@ -109,7 +101,7 @@ function notifyMessage(targetId, channel, activeChannel, msg) {
} }
if ( if (
options.settings.desktopNotifications && store.state.settings.desktopNotifications &&
"Notification" in window && "Notification" in window &&
Notification.permission === "granted" Notification.permission === "granted"
) { ) {
@ -136,7 +128,7 @@ function notifyMessage(targetId, channel, activeChannel, msg) {
const timestamp = Date.parse(msg.time); const timestamp = Date.parse(msg.time);
try { try {
if (webpush.hasServiceWorker) { if (store.state.hasServiceWorker) {
navigator.serviceWorker.ready.then((registration) => { navigator.serviceWorker.ready.then((registration) => {
registration.active.postMessage({ registration.active.postMessage({
type: "notification", type: "notification",
@ -155,9 +147,14 @@ function notifyMessage(targetId, channel, activeChannel, msg) {
timestamp: timestamp, timestamp: timestamp,
}); });
notify.addEventListener("click", function() { notify.addEventListener("click", function() {
window.focus();
button.trigger("click");
this.close(); this.close();
window.focus();
const channelTarget = store.getters.findChannel(targetId);
if (channelTarget) {
switchToChannel(channelTarget);
}
}); });
} }
} catch (exception) { } catch (exception) {

View File

@ -1,10 +1,12 @@
"use strict"; "use strict";
const socket = require("../socket"); import Vue from "vue";
const {vueApp, findChannel} = require("../vue");
import socket from "../socket";
import store from "../store";
socket.on("msg:preview", function(data) { socket.on("msg:preview", function(data) {
const {channel} = findChannel(data.chan); const {channel} = store.getters.findChannel(data.chan);
const message = channel.messages.find((m) => m.id === data.id); const message = channel.messages.find((m) => m.id === data.id);
if (!message) { if (!message) {
@ -14,6 +16,6 @@ socket.on("msg:preview", function(data) {
const previewIndex = message.previews.findIndex((m) => m.link === data.preview.link); const previewIndex = message.previews.findIndex((m) => m.link === data.preview.link);
if (previewIndex > -1) { if (previewIndex > -1) {
vueApp.$set(message.previews, previewIndex, data.preview); Vue.set(message.previews, previewIndex, data.preview);
} }
}); });

View File

@ -1,13 +1,11 @@
"use strict"; "use strict";
const $ = require("jquery"); import socket from "../socket";
const socket = require("../socket"); import store from "../store";
const {vueApp, findChannel} = require("../vue"); import {switchToChannel} from "../router";
socket.on("msg:special", function(data) { socket.on("msg:special", function(data) {
findChannel(data.chan).channel.data = data.data; const channel = store.getters.findChannel(data.chan);
channel.channel.data = data.data;
vueApp.$nextTick(() => { switchToChannel(channel.channel);
$(`#sidebar .chan[data-id="${data.chan}"]`).trigger("click");
});
}); });

View File

@ -1,10 +1,10 @@
"use strict"; "use strict";
const socket = require("../socket"); import socket from "../socket";
const {findChannel} = require("../vue"); import store from "../store";
socket.on("names", function(data) { socket.on("names", function(data) {
const channel = findChannel(data.id); const channel = store.getters.findChannel(data.id);
if (channel) { if (channel) {
channel.channel.users = data.users; channel.channel.users = data.users;

View File

@ -1,39 +1,32 @@
"use strict"; "use strict";
const $ = require("jquery"); import Vue from "vue";
const socket = require("../socket");
const templates = require("../../views"); import socket from "../socket";
const sidebar = $("#sidebar"); import store from "../store";
const utils = require("../utils"); import {switchToChannel} from "../router";
const {vueApp, initChannel, findChannel} = require("../vue");
socket.on("network", function(data) { socket.on("network", function(data) {
const network = data.networks[0]; const network = data.networks[0];
network.isJoinChannelShown = false; network.isJoinChannelShown = false;
network.isCollapsed = false; network.isCollapsed = false;
network.channels.forEach(initChannel); network.channels.forEach(store.getters.initChannel);
vueApp.networks.push(network); store.commit("networks", [...store.state.networks, network]);
switchToChannel(network.channels[0]);
vueApp.$nextTick(() => {
sidebar
.find(".chan")
.last()
.trigger("click");
});
$("#connect")
.find(".btn")
.prop("disabled", false);
}); });
socket.on("network:options", function(data) { socket.on("network:options", function(data) {
vueApp.networks.find((n) => n.uuid === data.network).serverOptions = data.serverOptions; const network = store.getters.findNetwork(data.network);
if (network) {
network.serverOptions = data.serverOptions;
}
}); });
socket.on("network:status", function(data) { socket.on("network:status", function(data) {
const network = vueApp.networks.find((n) => n.uuid === data.network); const network = store.getters.findNetwork(data.network);
if (!network) { if (!network) {
return; return;
@ -51,7 +44,7 @@ socket.on("network:status", function(data) {
}); });
socket.on("channel:state", function(data) { socket.on("channel:state", function(data) {
const channel = findChannel(data.chan); const channel = store.getters.findChannel(data.chan);
if (channel) { if (channel) {
channel.channel.state = data.state; channel.channel.state = data.state;
@ -59,22 +52,13 @@ socket.on("channel:state", function(data) {
}); });
socket.on("network:info", function(data) { socket.on("network:info", function(data) {
$("#connect") const network = store.getters.findNetwork(data.uuid);
.html(templates.windows.connect(data))
.find("form")
.on("submit", function() {
const uuid = $(this)
.find("input[name=uuid]")
.val();
const newName = $(this)
.find("#connect\\:name")
.val();
const network = vueApp.networks.find((n) => n.uuid === uuid); if (!network) {
network.name = network.channels[0].name = newName; return;
}
sidebar.find(`.network[data-uuid="${uuid}"] .chan.lobby .name`).click(); for (const key in data) {
}); Vue.set(network, key, data[key]);
}
utils.togglePasswordField("#connect .reveal-password");
}); });

View File

@ -1,10 +1,10 @@
"use strict"; "use strict";
const socket = require("../socket"); import socket from "../socket";
const {vueApp} = require("../vue"); import store from "../store";
socket.on("nick", function(data) { socket.on("nick", function(data) {
const network = vueApp.networks.find((n) => n.uuid === data.network); const network = store.getters.findNetwork(data.network);
if (network) { if (network) {
network.nick = data.nick; network.nick = data.nick;

View File

@ -1,8 +1,7 @@
"use strict"; "use strict";
const socket = require("../socket"); import socket from "../socket";
const utils = require("../utils"); import store from "../store";
const {vueApp, findChannel} = require("../vue");
// Sync unread badge and marker when other clients open a channel // Sync unread badge and marker when other clients open a channel
socket.on("open", function(id) { socket.on("open", function(id) {
@ -11,12 +10,12 @@ socket.on("open", function(id) {
} }
// Don't do anything if the channel is active on this client // Don't do anything if the channel is active on this client
if (vueApp.activeChannel && vueApp.activeChannel.channel.id === id) { if (store.state.activeChannel && store.state.activeChannel.channel.id === id) {
return; return;
} }
// Clear the unread badge // Clear the unread badge
const channel = findChannel(id); const channel = store.getters.findChannel(id);
if (channel) { if (channel) {
channel.channel.highlight = 0; channel.channel.highlight = 0;
@ -27,6 +26,4 @@ socket.on("open", function(id) {
channel.channel.messages[channel.channel.messages.length - 1].id; channel.channel.messages[channel.channel.messages.length - 1].id;
} }
} }
utils.synchronizeNotifiedState();
}); });

View File

@ -1,20 +1,16 @@
"use strict"; "use strict";
const $ = require("jquery"); import socket from "../socket";
const socket = require("../socket"); import store from "../store";
const utils = require("../utils"); import {switchToChannel} from "../router";
const {vueApp, findChannel} = require("../vue");
socket.on("part", function(data) { socket.on("part", function(data) {
// When parting from the active channel/query, jump to the network's lobby // When parting from the active channel/query, jump to the network's lobby
if (vueApp.activeChannel && vueApp.activeChannel.channel.id === data.chan) { if (store.state.activeChannel && store.state.activeChannel.channel.id === data.chan) {
$("#sidebar .chan[data-id='" + data.chan + "']") switchToChannel(store.state.activeChannel.network.channels[0]);
.closest(".network")
.find(".lobby")
.trigger("click");
} }
const channel = findChannel(data.chan); const channel = store.getters.findChannel(data.chan);
if (channel) { if (channel) {
channel.network.channels.splice( channel.network.channels.splice(
@ -22,6 +18,4 @@ socket.on("part", function(data) {
1 1
); );
} }
utils.synchronizeNotifiedState();
}); });

View File

@ -1,26 +1,24 @@
"use strict"; "use strict";
const $ = require("jquery"); import socket from "../socket";
const socket = require("../socket"); import {switchToChannel, navigate} from "../router";
const sidebar = $("#sidebar"); import store from "../store";
const {vueApp} = require("../vue");
socket.on("quit", function(data) { socket.on("quit", function(data) {
vueApp.networks.splice( // If we're in a channel, and it's on the network that is being removed,
vueApp.networks.findIndex((n) => n.uuid === data.network), // then open another channel window
1 const isCurrentNetworkBeingRemoved =
); store.state.activeChannel && store.state.activeChannel.network.uuid === data.network;
vueApp.$nextTick(() => { store.commit("removeNetwork", data.network);
const chan = sidebar.find(".chan");
if (chan.length === 0) { if (!isCurrentNetworkBeingRemoved) {
// Open the connect window return;
$("#footer .connect").trigger("click", { }
pushState: false,
}); if (store.state.networks.length > 0) {
} else { switchToChannel(store.state.networks[0].channels[0]);
chan.eq(0).trigger("click"); } else {
} navigate("Connect");
}); }
}); });

View File

@ -1,37 +1,9 @@
"use strict"; "use strict";
const $ = require("jquery"); import socket from "../socket";
const Auth = require("../auth"); import store from "../store";
const socket = require("../socket");
const templates = require("../../views");
socket.on("sessions:list", function(data) { socket.on("sessions:list", function(data) {
data.sort((a, b) => b.lastUse - a.lastUse); data.sort((a, b) => b.lastUse - a.lastUse);
store.commit("sessions", data);
let html = "";
data.forEach((connection) => {
if (connection.current) {
$("#session-current").html(templates.session(connection));
return;
}
html += templates.session(connection);
});
if (html.length === 0) {
html = "<p><em>You are not currently logged in to any other device.</em></p>";
}
$("#session-list").html(html);
});
$("#settings").on("click", ".remove-session", function() {
const token = $(this).data("token");
if (token) {
socket.emit("sign-out", token);
} else {
socket.emit("sign-out");
Auth.signout();
}
}); });

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