Merge pull request #3524 from thelounge/vue
Complete porting The Lounge client to the Vue.js framework
This commit is contained in:
commit
e74c35687e
@ -1,5 +1,2 @@
|
|||||||
# third party
|
|
||||||
client/js/libs/jquery/*.js
|
|
||||||
|
|
||||||
public/
|
public/
|
||||||
coverage/
|
coverage/
|
||||||
|
@ -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
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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 = "";
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
200
client/components/ContextMenu.vue
Normal file
200
client/components/ContextMenu.vue
Normal 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>
|
@ -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",
|
||||||
|
354
client/components/ImageViewer.vue
Normal file
354
client/components/ImageViewer.vue
Normal 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>
|
30
client/components/InlineChannel.vue
Normal file
30
client/components/InlineChannel.vue
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<template>
|
||||||
|
<span class="inline-channel" dir="auto" role="button" tabindex="0" @click="onClick"
|
||||||
|
><slot></slot
|
||||||
|
></span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import socket from "../js/socket";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "InlineChannel",
|
||||||
|
props: {
|
||||||
|
channel: String,
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onClick() {
|
||||||
|
const existingChannel = this.$store.getters.findChannelOnCurrentNetwork(this.channel);
|
||||||
|
|
||||||
|
if (existingChannel) {
|
||||||
|
this.$root.switchToChannel(existingChannel);
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.emit("input", {
|
||||||
|
target: this.$store.state.activeChannel.channel.id,
|
||||||
|
text: "/join " + this.channel,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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",
|
||||||
|
@ -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" /> <ParsedMessage
|
<Username :user="message.from" dir="auto" /> <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";
|
||||||
|
@ -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 {
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
242
client/components/NetworkForm.vue
Normal file
242
client/components/NetworkForm.vue
Normal 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>
|
@ -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;
|
||||||
|
@ -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() {
|
||||||
|
@ -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",
|
||||||
|
33
client/components/RevealPassword.vue
Normal file
33
client/components/RevealPassword.vue
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<slot :isVisible="isVisible" />
|
||||||
|
<span
|
||||||
|
ref="revealButton"
|
||||||
|
type="button"
|
||||||
|
:class="[
|
||||||
|
'reveal-password tooltipped tooltipped-n tooltipped-no-delay',
|
||||||
|
{'reveal-password-visible': isVisible},
|
||||||
|
]"
|
||||||
|
:aria-label="isVisible ? 'Hide password' : 'Show password'"
|
||||||
|
@click="onClick"
|
||||||
|
>
|
||||||
|
<span :aria-label="isVisible ? 'Hide password' : 'Show password'" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: "RevealPassword",
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isVisible: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onClick() {
|
||||||
|
this.isVisible = !this.isVisible;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
35
client/components/RoutedChat.vue
Normal file
35
client/components/RoutedChat.vue
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
<template>
|
||||||
|
<Chat v-if="activeChannel" :network="activeChannel.network" :channel="activeChannel.channel" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Temporary component for routing channels and lobbies
|
||||||
|
import Chat from "./Chat.vue";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "RoutedChat",
|
||||||
|
components: {
|
||||||
|
Chat,
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
activeChannel() {
|
||||||
|
const chanId = parseInt(this.$route.params.id, 10);
|
||||||
|
const channel = this.$store.getters.findChannel(chanId);
|
||||||
|
return channel;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
activeChannel() {
|
||||||
|
this.setActiveChannel();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.setActiveChannel();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
setActiveChannel() {
|
||||||
|
this.$store.commit("activeChannel", this.activeChannel);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
50
client/components/Session.vue
Normal file
50
client/components/Session.vue
Normal 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>
|
190
client/components/Sidebar.vue
Normal file
190
client/components/Sidebar.vue
Normal 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>
|
9
client/components/SidebarToggle.vue
Normal file
9
client/components/SidebarToggle.vue
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<template>
|
||||||
|
<button class="lt" aria-label="Toggle channel list" @click="$store.commit('toggleSidebar')" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: "SidebarToggle",
|
||||||
|
};
|
||||||
|
</script>
|
@ -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>
|
||||||
|
@ -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>
|
|
69
client/components/VersionChecker.vue
Normal file
69
client/components/VersionChecker.vue
Normal 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>
|
87
client/components/Windows/Changelog.vue
Normal file
87
client/components/Windows/Changelog.vue
Normal 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>
|
112
client/components/Windows/Connect.vue
Normal file
112
client/components/Windows/Connect.vue
Normal 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>
|
703
client/components/Windows/Help.vue
Normal file
703
client/components/Windows/Help.vue
Normal 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>0—15</code> to select the desired color, or use the autocompletion
|
||||||
|
menu to choose a color name (see below).
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Background color can be specified by putting a comma and another integer in
|
||||||
|
the range <code>0—15</code> after the foreground color number
|
||||||
|
(autocompletion works too).
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
A color reference can be found
|
||||||
|
<a
|
||||||
|
href="https://modern.ircdocs.horse/formatting.html#colors"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>here</a
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<span v-if="!isApple"><kbd>Ctrl</kbd> <kbd>B</kbd></span>
|
||||||
|
<span v-else><kbd>⌘</kbd> <kbd>B</kbd></span>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>
|
||||||
|
Mark all text typed after this shortcut as
|
||||||
|
<span class="irc-bold">bold</span>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<span v-if="!isApple"><kbd>Ctrl</kbd> <kbd>U</kbd></span>
|
||||||
|
<span v-else><kbd>⌘</kbd> <kbd>U</kbd></span>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>
|
||||||
|
Mark all text typed after this shortcut as
|
||||||
|
<span class="irc-underline">underlined</span>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<span v-if="!isApple"><kbd>Ctrl</kbd> <kbd>I</kbd></span>
|
||||||
|
<span v-else><kbd>⌘</kbd> <kbd>I</kbd></span>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>
|
||||||
|
Mark all text typed after this shortcut as
|
||||||
|
<span class="irc-italic">italics</span>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<span v-if="!isApple"><kbd>Ctrl</kbd> <kbd>S</kbd></span>
|
||||||
|
<span v-else><kbd>⌘</kbd> <kbd>S</kbd></span>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>
|
||||||
|
Mark all text typed after this shortcut as
|
||||||
|
<span class="irc-strikethrough">struck through</span>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<span v-if="!isApple"><kbd>Ctrl</kbd> <kbd>M</kbd></span>
|
||||||
|
<span v-else><kbd>⌘</kbd> <kbd>M</kbd></span>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>
|
||||||
|
Mark all text typed after this shortcut as
|
||||||
|
<span class="irc-monospace">monospaced</span>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<span v-if="!isApple"><kbd>Ctrl</kbd> <kbd>O</kbd></span>
|
||||||
|
<span v-else><kbd>⌘</kbd> <kbd>O</kbd></span>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>
|
||||||
|
Mark all text typed after this shortcut to be reset to its original
|
||||||
|
formatting.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Autocompletion</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
To auto-complete nicknames, channels, commands, and emoji, type one of the
|
||||||
|
characters below to open a suggestion list. Use the <kbd>↑</kbd> and
|
||||||
|
<kbd>↓</kbd> keys to highlight an item, and insert it by pressing <kbd>Tab</kbd> or
|
||||||
|
<kbd>Enter</kbd> (or by clicking the desired item).
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Autocompletion can be disabled in settings.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<code>@</code>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>Nickname</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<code>#</code>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>Channel</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<code>/</code>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>Commands (see list of commands below)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<code>:</code>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>
|
||||||
|
Emoji (note: requires two search characters, to avoid conflicting with
|
||||||
|
common emoticons like <code>:)</code>)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Commands</h2>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<code>/away [message]</code>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>Mark yourself as away with an optional message.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<code>/back</code>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>Remove your away status (set with <code>/away</code>).</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<code>/ban nick</code>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>
|
||||||
|
Ban (<code>+b</code>) a user from the current channel. This can be a
|
||||||
|
nickname or a hostmask.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<code>/banlist</code>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>Load the banlist for the current channel.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<code>/collapse</code>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>
|
||||||
|
Collapse all previews in the current channel (opposite of
|
||||||
|
<code>/expand</code>)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<code>/connect host [port]</code>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>
|
||||||
|
Connect to a new IRC network. If <code>port</code> starts with a
|
||||||
|
<code>+</code> sign, the connection will be made secure using TLS.
|
||||||
|
</p>
|
||||||
|
<p>Alias: <code>/server</code></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<code>/ctcp target cmd [args]</code>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>
|
||||||
|
Send a <abbr title="Client-to-client protocol">CTCP</abbr>
|
||||||
|
request. Read more about this on
|
||||||
|
<a
|
||||||
|
href="https://en.wikipedia.org/wiki/Client-to-client_protocol"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>the dedicated Wikipedia article</a
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<code>/deop nick [...nick]</code>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>
|
||||||
|
Remove op (<code>-o</code>) from one or several users in the current
|
||||||
|
channel.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<code>/devoice nick [...nick]</code>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>
|
||||||
|
Remove voice (<code>-v</code>) from one or several users in the current
|
||||||
|
channel.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<code>/disconnect [message]</code>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>
|
||||||
|
Disconnect from the current network with an optionally-provided message.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<code>/expand</code>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>
|
||||||
|
Expand all previews in the current channel (opposite of
|
||||||
|
<code>/collapse</code>)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<code>/invite nick [channel]</code>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>
|
||||||
|
Invite a user to the specified channel. If
|
||||||
|
<code>channel</code> is omitted, user will be invited to the current
|
||||||
|
channel.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<code>/ignore nick</code>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>
|
||||||
|
Block any messages from the specified user on the current network. This can
|
||||||
|
be a nickname or a hostmask.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<code>/ignorelist</code>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>Load the list of ignored users for the current network.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<code>/join channel</code>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>Join a channel.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<code>/kick nick</code>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>Kick a user from the current channel.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<code>/list</code>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>Retrieve a list of available channels on this network.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<code>/me message</code>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>
|
||||||
|
Send an action message to the current channel. The Lounge will display it
|
||||||
|
inline, as if the message was posted in the third person.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<code>/mode flags [args]</code>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>
|
||||||
|
Set the given flags to the current channel if the active window is a
|
||||||
|
channel, another user if the active window is a private message window, or
|
||||||
|
yourself if the current window is a server window.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<code>/msg channel message</code>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>Send a message to the specified channel.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<code>/nick newnick</code>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>Change your nickname on the current network.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<code>/notice channel message</code>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>Sends a notice message to the specified channel.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<code>/op nick [...nick]</code>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>
|
||||||
|
Give op (<code>+o</code>) to one or several users in the current channel.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<code>/part [channel]</code>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>
|
||||||
|
Close the specified channel or private message window, or the current
|
||||||
|
channel if <code>channel</code> is omitted.
|
||||||
|
</p>
|
||||||
|
<p>Aliases: <code>/close</code>, <code>/leave</code></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<code>/rejoin</code>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>
|
||||||
|
Leave and immediately rejoin the current channel. Useful to quickly get op
|
||||||
|
from ChanServ in an empty channel, for example.
|
||||||
|
</p>
|
||||||
|
<p>Alias: <code>/cycle</code></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<code>/query nick</code>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>Send a private message to the specified user.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<code>/quit [message]</code>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>
|
||||||
|
Disconnect from the current network with an optional message.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<code>/raw message</code>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>Send a raw message to the current IRC network.</p>
|
||||||
|
<p>Aliases: <code>/quote</code>, <code>/send</code></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<code>/slap nick</code>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>Slap someone in the current channel with a trout!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<code>/topic [newtopic]</code>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>
|
||||||
|
Get the topic in the current channel. If <code>newtopic</code> is specified,
|
||||||
|
sets the topic in the current channel.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<code>/unban nick</code>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>
|
||||||
|
Unban (<code>-b</code>) a user from the current channel. This can be a
|
||||||
|
nickname or a hostmask.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<code>/unignore nick</code>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>
|
||||||
|
Unblock messages from the specified user on the current network. This can be
|
||||||
|
a nickname or a hostmask.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<code>/voice nick [...nick]</code>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>
|
||||||
|
Give voice (<code>+v</code>) to one or several users in the current channel.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-item">
|
||||||
|
<div class="subject">
|
||||||
|
<code>/whois nick</code>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<p>
|
||||||
|
Retrieve information about the given user on the current network.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import SidebarToggle from "../SidebarToggle.vue";
|
||||||
|
import VersionChecker from "../VersionChecker.vue";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "Help",
|
||||||
|
components: {
|
||||||
|
SidebarToggle,
|
||||||
|
VersionChecker,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isApple: navigator.platform.match(/(Mac|iPhone|iPod|iPad)/i) || false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
50
client/components/Windows/NetworkEdit.vue
Normal file
50
client/components/Windows/NetworkEdit.vue
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<template>
|
||||||
|
<NetworkForm
|
||||||
|
v-if="networkData"
|
||||||
|
:handle-submit="handleSubmit"
|
||||||
|
:defaults="networkData"
|
||||||
|
:disabled="disabled"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import socket from "../../js/socket";
|
||||||
|
import NetworkForm from "../NetworkForm.vue";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "NetworkEdit",
|
||||||
|
components: {
|
||||||
|
NetworkForm,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
disabled: false,
|
||||||
|
networkData: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
"$route.params.uuid"() {
|
||||||
|
this.setNetworkData();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.setNetworkData();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
setNetworkData() {
|
||||||
|
socket.emit("network:get", this.$route.params.uuid);
|
||||||
|
this.networkData = this.$store.getters.findNetwork(this.$route.params.uuid);
|
||||||
|
},
|
||||||
|
handleSubmit(data) {
|
||||||
|
this.disabled = true;
|
||||||
|
socket.emit("network:edit", data);
|
||||||
|
|
||||||
|
// TODO: move networks to vuex and update state when the network info comes in
|
||||||
|
const network = this.$store.getters.findNetwork(data.uuid);
|
||||||
|
network.name = network.channels[0].name = data.name;
|
||||||
|
|
||||||
|
this.$root.switchToChannel(network.channels[0]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
590
client/components/Windows/Settings.vue
Normal file
590
client/components/Windows/Settings.vue
Normal 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>
|
105
client/components/Windows/SignIn.vue
Normal file
105
client/components/Windows/SignIn.vue
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
<template>
|
||||||
|
<div id="sign-in" class="window" role="tabpanel" aria-label="Sign-in">
|
||||||
|
<form class="container" method="post" action="" @submit="onSubmit">
|
||||||
|
<img
|
||||||
|
src="img/logo-vertical-transparent-bg.svg"
|
||||||
|
class="logo"
|
||||||
|
alt="The Lounge"
|
||||||
|
width="256"
|
||||||
|
height="170"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
src="img/logo-vertical-transparent-bg-inverted.svg"
|
||||||
|
class="logo-inverted"
|
||||||
|
alt="The Lounge"
|
||||||
|
width="256"
|
||||||
|
height="170"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label for="signin-username">Username</label>
|
||||||
|
<input
|
||||||
|
id="signin-username"
|
||||||
|
ref="username"
|
||||||
|
class="input"
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
autocapitalize="none"
|
||||||
|
autocorrect="off"
|
||||||
|
autocomplete="username"
|
||||||
|
:value="getStoredUser()"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="password-container">
|
||||||
|
<label for="signin-password">Password</label>
|
||||||
|
<RevealPassword v-slot:default="slotProps">
|
||||||
|
<input
|
||||||
|
id="signin-password"
|
||||||
|
ref="password"
|
||||||
|
:type="slotProps.isVisible ? 'text' : 'password'"
|
||||||
|
name="password"
|
||||||
|
class="input"
|
||||||
|
autocapitalize="none"
|
||||||
|
autocorrect="off"
|
||||||
|
autocomplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</RevealPassword>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="errorShown" class="error">Authentication failed.</div>
|
||||||
|
|
||||||
|
<button :disabled="inFlight" type="submit" class="btn">Sign in</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import storage from "../../js/localStorage";
|
||||||
|
import socket from "../../js/socket";
|
||||||
|
import RevealPassword from "../RevealPassword.vue";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "SignIn",
|
||||||
|
components: {
|
||||||
|
RevealPassword,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
inFlight: false,
|
||||||
|
errorShown: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
socket.on("auth:failed", this.onAuthFailed);
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
socket.off("auth:failed", this.onAuthFailed);
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onAuthFailed() {
|
||||||
|
this.inFlight = false;
|
||||||
|
this.errorShown = true;
|
||||||
|
},
|
||||||
|
onSubmit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
this.inFlight = true;
|
||||||
|
this.errorShown = false;
|
||||||
|
|
||||||
|
const values = {
|
||||||
|
user: this.$refs.username.value,
|
||||||
|
password: this.$refs.password.value,
|
||||||
|
};
|
||||||
|
|
||||||
|
storage.set("user", values.user);
|
||||||
|
|
||||||
|
socket.emit("auth:perform", values);
|
||||||
|
},
|
||||||
|
getStoredUser() {
|
||||||
|
return storage.get("user");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
@ -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 <=== ) */
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
};
|
}
|
||||||
|
@ -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};
|
||||||
|
@ -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};
|
||||||
|
@ -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;
|
||||||
}, {});
|
}, {});
|
||||||
|
@ -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};
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
|
@ -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;
|
|
||||||
}
|
|
@ -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();
|
|
||||||
}
|
|
@ -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++) {
|
295
client/js/helpers/contextMenu.js
Normal file
295
client/js/helpers/contextMenu.js
Normal 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",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
@ -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));
|
@ -11,4 +11,4 @@ function anyIntersection(a, b) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = anyIntersection;
|
export default anyIntersection;
|
@ -32,4 +32,4 @@ function fill(existingEntries, text) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = fill;
|
export default fill;
|
@ -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;
|
@ -17,4 +17,4 @@ function findEmoji(text) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = findEmoji;
|
export default findEmoji;
|
@ -25,4 +25,4 @@ function findNames(text, users) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = findNames;
|
export default findNames;
|
@ -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;
|
@ -234,4 +234,4 @@ function prepare(text) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = prepare;
|
export default prepare;
|
15
client/js/helpers/isChannelCollapsed.js
Normal file
15
client/js/helpers/isChannelCollapsed.js
Normal 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;
|
||||||
|
};
|
5
client/js/helpers/localetime.js
Normal file
5
client/js/helpers/localetime.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
export default (time) => dayjs(time).format("D MMMM YYYY, HH:mm:ss");
|
@ -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;
|
56
client/js/helpers/parseIrcUri.js
Normal file
56
client/js/helpers/parseIrcUri.js
Normal 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;
|
||||||
|
};
|
@ -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();
|
||||||
}
|
}
|
@ -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"});
|
||||||
|
}
|
||||||
|
@ -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);
|
|
||||||
};
|
|
@ -1,7 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
|
|
||||||
const moment = require("moment");
|
|
||||||
|
|
||||||
module.exports = function(time) {
|
|
||||||
return moment(time).format("D MMMM YYYY, HH:mm:ss");
|
|
||||||
};
|
|
@ -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}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -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);
|
||||||
|
@ -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();
|
||||||
},
|
},
|
||||||
|
@ -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();
|
|
||||||
};
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
@ -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
144
client/js/router.js
Normal 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
115
client/js/settings.js
Normal 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;
|
||||||
|
}
|
@ -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;
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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);
|
|
||||||
});
|
|
@ -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}));
|
|
||||||
}
|
|
||||||
|
@ -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) {
|
||||||
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
69
client/js/socket-events/connection.js
Normal file
69
client/js/socket-events/connection.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -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";
|
||||||
|
@ -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});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
@ -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;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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) {
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -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");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
@ -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;
|
||||||
|
@ -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");
|
|
||||||
});
|
});
|
||||||
|
@ -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;
|
||||||
|
@ -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();
|
|
||||||
});
|
});
|
||||||
|
@ -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();
|
|
||||||
});
|
});
|
||||||
|
@ -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");
|
||||||
});
|
}
|
||||||
});
|
});
|
||||||
|
@ -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
Loading…
Reference in New Issue
Block a user