Merge pull request #2647 from thelounge/vue
Port sidebar, chat, user list, state to Vue
This commit is contained in:
commit
ae72d5828c
@ -85,8 +85,20 @@ rules:
|
||||
space-in-parens: [error, never]
|
||||
space-infix-ops: error
|
||||
spaced-comment: [error, always]
|
||||
strict: error
|
||||
strict: off
|
||||
template-curly-spacing: error
|
||||
yoda: error
|
||||
vue/html-indent: [error, tab]
|
||||
vue/require-default-prop: off
|
||||
vue/no-v-html: off
|
||||
vue/no-use-v-if-with-v-for: off
|
||||
vue/html-closing-bracket-newline: [error, {singleline: never, multiline: never}]
|
||||
vue/multiline-html-element-content-newline: off
|
||||
vue/singleline-html-element-content-newline: off
|
||||
|
||||
extends: eslint:recommended
|
||||
plugins:
|
||||
- vue
|
||||
|
||||
extends:
|
||||
- eslint:recommended
|
||||
- plugin:vue/recommended
|
||||
|
142
client/components/App.vue
Normal file
142
client/components/App.vue
Normal file
@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div
|
||||
id="viewport"
|
||||
role="tablist">
|
||||
<aside id="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">
|
||||
</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">
|
||||
<Chat
|
||||
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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const throttle = require("lodash/throttle");
|
||||
|
||||
import NetworkList from "./NetworkList.vue";
|
||||
import Chat from "./Chat.vue";
|
||||
|
||||
export default {
|
||||
name: "App",
|
||||
components: {
|
||||
NetworkList,
|
||||
Chat,
|
||||
},
|
||||
props: {
|
||||
activeChannel: Object,
|
||||
networks: Array,
|
||||
},
|
||||
mounted() {
|
||||
// Make a single throttled resize listener available to all components
|
||||
this.debouncedResize = throttle(() => {
|
||||
this.$root.$emit("resize");
|
||||
}, 100);
|
||||
|
||||
window.addEventListener("resize", this.debouncedResize, {passive: true});
|
||||
|
||||
// Emit a daychange event every time the day changes so date markers know when to update themselves
|
||||
const emitDayChange = () => {
|
||||
this.$root.$emit("daychange");
|
||||
// This should always be 24h later but re-computing exact value just in case
|
||||
this.dayChangeTimeout = setTimeout(emitDayChange, this.msUntilNextDay());
|
||||
};
|
||||
|
||||
this.dayChangeTimeout = setTimeout(emitDayChange, this.msUntilNextDay());
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener("resize", this.debouncedResize);
|
||||
clearTimeout(this.dayChangeTimeout);
|
||||
},
|
||||
methods: {
|
||||
isPublic: () => document.body.classList.contains("public"),
|
||||
msUntilNextDay() {
|
||||
// Compute how many milliseconds are remaining until the next day starts
|
||||
const today = new Date();
|
||||
const tommorow = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1);
|
||||
|
||||
return tommorow - today;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
46
client/components/Channel.vue
Normal file
46
client/components/Channel.vue
Normal file
@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<ChannelWrapper
|
||||
:network="network"
|
||||
:channel="channel"
|
||||
:active-channel="activeChannel">
|
||||
<span class="name">{{ channel.name }}</span>
|
||||
<span
|
||||
v-if="channel.unread"
|
||||
:class="{ highlight: channel.highlight }"
|
||||
class="badge">{{ channel.unread | roundBadgeNumber }}</span>
|
||||
<template v-if="channel.type === 'channel'">
|
||||
<span
|
||||
class="close-tooltip tooltipped tooltipped-w"
|
||||
aria-label="Leave">
|
||||
<button
|
||||
class="close"
|
||||
aria-label="Leave" />
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span
|
||||
class="close-tooltip tooltipped tooltipped-w"
|
||||
aria-label="Close">
|
||||
<button
|
||||
class="close"
|
||||
aria-label="Close" />
|
||||
</span>
|
||||
</template>
|
||||
</ChannelWrapper>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ChannelWrapper from "./ChannelWrapper.vue";
|
||||
|
||||
export default {
|
||||
name: "Channel",
|
||||
components: {
|
||||
ChannelWrapper,
|
||||
},
|
||||
props: {
|
||||
activeChannel: Object,
|
||||
network: Object,
|
||||
channel: Object,
|
||||
},
|
||||
};
|
||||
</script>
|
53
client/components/ChannelWrapper.vue
Normal file
53
client/components/ChannelWrapper.vue
Normal file
@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="!network.isCollapsed || channel.highlight || channel.type === 'lobby' || (activeChannel && channel === activeChannel.channel)"
|
||||
:class="[
|
||||
channel.type,
|
||||
{ active: activeChannel && channel === activeChannel.channel },
|
||||
{ 'channel-is-parted': channel.type === 'channel' && channel.state === 0 }
|
||||
]"
|
||||
:aria-label="getAriaLabel()"
|
||||
:title="getAriaLabel()"
|
||||
:data-id="channel.id"
|
||||
:data-target="'#chan-' + channel.id"
|
||||
:data-name="channel.name"
|
||||
:aria-controls="'#chan-' + channel.id"
|
||||
:aria-selected="activeChannel && channel === activeChannel.channel"
|
||||
class="chan"
|
||||
role="tab">
|
||||
<slot
|
||||
:network="network"
|
||||
:channel="channel"
|
||||
:activeChannel="activeChannel" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "ChannelWrapper",
|
||||
props: {
|
||||
network: Object,
|
||||
channel: Object,
|
||||
activeChannel: Object,
|
||||
},
|
||||
methods: {
|
||||
getAriaLabel() {
|
||||
const extra = [];
|
||||
|
||||
if (this.channel.unread > 0) {
|
||||
extra.push(`${this.channel.unread} unread`);
|
||||
}
|
||||
|
||||
if (this.channel.highlight > 0) {
|
||||
extra.push(`${this.channel.highlight} mention`);
|
||||
}
|
||||
|
||||
if (extra.length > 0) {
|
||||
return `${this.channel.name} (${extra.join(", ")})`;
|
||||
}
|
||||
|
||||
return this.channel.name;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
124
client/components/Chat.vue
Normal file
124
client/components/Chat.vue
Normal file
@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div
|
||||
id="chat-container"
|
||||
class="window">
|
||||
<div
|
||||
id="chat"
|
||||
:data-id="channel.id"
|
||||
:class="{
|
||||
'hide-motd': !this.$root.settings.motd,
|
||||
'colored-nicks': this.$root.settings.coloredNicks,
|
||||
'show-seconds': this.$root.settings.showSeconds,
|
||||
}">
|
||||
<div
|
||||
:id="'chan-' + channel.id"
|
||||
:class="[channel.type, 'chan', 'active']"
|
||||
:data-id="channel.id"
|
||||
:data-type="channel.type"
|
||||
:aria-label="channel.name"
|
||||
role="tabpanel">
|
||||
<div class="header">
|
||||
<button
|
||||
class="lt"
|
||||
aria-label="Toggle channel list" />
|
||||
<span class="title">{{ channel.name }}</span>
|
||||
<span
|
||||
:title="channel.topic"
|
||||
class="topic"><ParsedMessage
|
||||
v-if="channel.topic"
|
||||
:network="network"
|
||||
:text="channel.topic" /></span>
|
||||
<button
|
||||
class="menu"
|
||||
aria-label="Open the context menu" />
|
||||
<span
|
||||
v-if="channel.type === 'channel'"
|
||||
class="rt-tooltip tooltipped tooltipped-w"
|
||||
aria-label="Toggle user list">
|
||||
<button
|
||||
class="rt"
|
||||
aria-label="Toggle user list" />
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="channel.type === 'special'"
|
||||
class="chat-content">
|
||||
<div class="chat">
|
||||
<div class="messages">
|
||||
<div class="msg">
|
||||
<Component
|
||||
:is="specialComponent"
|
||||
:network="network"
|
||||
:channel="channel" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="chat-content">
|
||||
<div
|
||||
:class="['scroll-down', {'scroll-down-shown': !channel.scrolledToBottom}]"
|
||||
@click="$refs.messageList.jumpToBottom()">
|
||||
<div class="scroll-down-arrow" />
|
||||
</div>
|
||||
<MessageList
|
||||
ref="messageList"
|
||||
:network="network"
|
||||
:channel="channel" />
|
||||
<ChatUserList
|
||||
v-if="channel.type === 'channel'"
|
||||
:channel="channel" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="this.$root.currentUserVisibleError"
|
||||
id="user-visible-error"
|
||||
@click="hideUserVisibleError">{{ this.$root.currentUserVisibleError }}</div>
|
||||
<span id="upload-progressbar" />
|
||||
<ChatInput
|
||||
:network="network"
|
||||
:channel="channel" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ParsedMessage from "./ParsedMessage.vue";
|
||||
import MessageList from "./MessageList.vue";
|
||||
import ChatInput from "./ChatInput.vue";
|
||||
import ChatUserList from "./ChatUserList.vue";
|
||||
import ListBans from "./Special/ListBans.vue";
|
||||
import ListChannels from "./Special/ListChannels.vue";
|
||||
import ListIgnored from "./Special/ListIgnored.vue";
|
||||
|
||||
export default {
|
||||
name: "Chat",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
MessageList,
|
||||
ChatInput,
|
||||
ChatUserList,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
channel: Object,
|
||||
},
|
||||
computed: {
|
||||
specialComponent() {
|
||||
switch (this.channel.special) {
|
||||
case "list_bans": return ListBans;
|
||||
case "list_channels": return ListChannels;
|
||||
case "list_ignored": return ListIgnored;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
hideUserVisibleError() {
|
||||
this.$root.currentUserVisibleError = null;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
226
client/components/ChatInput.vue
Normal file
226
client/components/ChatInput.vue
Normal file
@ -0,0 +1,226 @@
|
||||
<template>
|
||||
<form
|
||||
id="form"
|
||||
method="post"
|
||||
action=""
|
||||
@submit.prevent="onSubmit">
|
||||
<span id="nick">{{ network.nick }}</span>
|
||||
<textarea
|
||||
id="input"
|
||||
ref="input"
|
||||
:value="channel.pendingMessage"
|
||||
:placeholder="getInputPlaceholder(channel)"
|
||||
:aria-label="getInputPlaceholder(channel)"
|
||||
class="mousetrap"
|
||||
@input="setPendingMessage"
|
||||
@keypress.enter.exact.prevent="onSubmit" />
|
||||
<span
|
||||
v-if="this.$root.isFileUploadEnabled"
|
||||
id="upload-tooltip"
|
||||
class="tooltipped tooltipped-w tooltipped-no-touch"
|
||||
aria-label="Upload File"
|
||||
@click="openFileUpload">
|
||||
<input
|
||||
id="upload-input"
|
||||
ref="uploadInput"
|
||||
type="file"
|
||||
multiple>
|
||||
<button
|
||||
id="upload"
|
||||
type="button"
|
||||
aria-label="Upload file"
|
||||
:disabled="!this.$root.isConnected" />
|
||||
</span>
|
||||
<span
|
||||
id="submit-tooltip"
|
||||
class="tooltipped tooltipped-w tooltipped-no-touch"
|
||||
aria-label="Send message">
|
||||
<button
|
||||
id="submit"
|
||||
type="submit"
|
||||
aria-label="Send message"
|
||||
:disabled="!this.$root.isConnected" />
|
||||
</span>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const commands = require("../js/commands/index");
|
||||
const socket = require("../js/socket");
|
||||
const upload = require("../js/upload");
|
||||
const Mousetrap = require("mousetrap");
|
||||
const {wrapCursor} = require("undate");
|
||||
|
||||
const formattingHotkeys = {
|
||||
k: "\x03",
|
||||
b: "\x02",
|
||||
u: "\x1F",
|
||||
i: "\x1D",
|
||||
o: "\x0F",
|
||||
s: "\x1e",
|
||||
m: "\x11",
|
||||
};
|
||||
|
||||
// Autocomplete bracket and quote characters like in a modern IDE
|
||||
// For example, select `text`, press `[` key, and it becomes `[text]`
|
||||
const bracketWraps = {
|
||||
'"': '"',
|
||||
"'": "'",
|
||||
"(": ")",
|
||||
"<": ">",
|
||||
"[": "]",
|
||||
"{": "}",
|
||||
"*": "*",
|
||||
"`": "`",
|
||||
"~": "~",
|
||||
"_": "_",
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "ChatInput",
|
||||
props: {
|
||||
network: Object,
|
||||
channel: Object,
|
||||
},
|
||||
watch: {
|
||||
"channel.pendingMessage"() {
|
||||
this.setInputSize();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (this.$root.settings.autocomplete) {
|
||||
require("../js/autocompletion").enable(this.$refs.input);
|
||||
}
|
||||
|
||||
const inputTrap = Mousetrap(this.$refs.input);
|
||||
|
||||
for (const hotkey in formattingHotkeys) {
|
||||
inputTrap.bind("mod+" + hotkey, function(e) {
|
||||
// Key is lowercased because keybinds also get processed if caps lock is on
|
||||
const modifier = formattingHotkeys[e.key.toLowerCase()];
|
||||
|
||||
wrapCursor(
|
||||
e.target,
|
||||
modifier,
|
||||
e.target.selectionStart === e.target.selectionEnd ? "" : modifier
|
||||
);
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
inputTrap.bind(Object.keys(bracketWraps), function(e) {
|
||||
if (e.target.selectionStart !== e.target.selectionEnd) {
|
||||
wrapCursor(e.target, e.key, bracketWraps[e.key]);
|
||||
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
inputTrap.bind(["up", "down"], (e, key) => {
|
||||
if (this.$root.isAutoCompleting || e.target.selectionStart !== e.target.selectionEnd) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.channel.inputHistoryPosition === 0) {
|
||||
this.channel.inputHistory[this.channel.inputHistoryPosition] = this.channel.pendingMessage;
|
||||
}
|
||||
|
||||
if (key === "up") {
|
||||
if (this.channel.inputHistoryPosition < this.channel.inputHistory.length - 1) {
|
||||
this.channel.inputHistoryPosition++;
|
||||
}
|
||||
} else if (this.channel.inputHistoryPosition > 0) {
|
||||
this.channel.inputHistoryPosition--;
|
||||
}
|
||||
|
||||
this.channel.pendingMessage = this.$refs.input.value = this.channel.inputHistory[this.channel.inputHistoryPosition];
|
||||
this.setInputSize();
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
if (this.$root.isFileUploadEnabled) {
|
||||
upload.initialize();
|
||||
}
|
||||
},
|
||||
destroyed() {
|
||||
require("../js/autocompletion").disable();
|
||||
},
|
||||
methods: {
|
||||
setPendingMessage(e) {
|
||||
this.channel.pendingMessage = e.target.value;
|
||||
this.channel.inputHistoryPosition = 0;
|
||||
this.setInputSize();
|
||||
},
|
||||
setInputSize() {
|
||||
this.$nextTick(() => {
|
||||
const style = window.getComputedStyle(this.$refs.input);
|
||||
const lineHeight = parseFloat(style.lineHeight, 10) || 1;
|
||||
|
||||
// Start by resetting height before computing as scrollHeight does not
|
||||
// decrease when deleting characters
|
||||
this.$refs.input.style.height = "";
|
||||
|
||||
// Use scrollHeight to calculate how many lines there are in input, and ceil the value
|
||||
// because some browsers tend to incorrently round the values when using high density
|
||||
// displays or using page zoom feature
|
||||
this.$refs.input.style.height = Math.ceil(this.$refs.input.scrollHeight / lineHeight) * lineHeight + "px";
|
||||
});
|
||||
},
|
||||
getInputPlaceholder(channel) {
|
||||
if (channel.type === "channel" || channel.type === "query") {
|
||||
return `Write to ${channel.name}`;
|
||||
}
|
||||
|
||||
return "";
|
||||
},
|
||||
onSubmit() {
|
||||
// Triggering click event opens the virtual keyboard on mobile
|
||||
// This can only be called from another interactive event (e.g. button click)
|
||||
this.$refs.input.click();
|
||||
this.$refs.input.focus();
|
||||
|
||||
if (!this.$root.isConnected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const target = this.channel.id;
|
||||
const text = this.channel.pendingMessage;
|
||||
|
||||
if (text.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.channel.inputHistoryPosition = 0;
|
||||
this.channel.pendingMessage = "";
|
||||
this.$refs.input.value = "";
|
||||
this.setInputSize();
|
||||
|
||||
// Store new message in history if last message isn't already equal
|
||||
if (this.channel.inputHistory[1] !== text) {
|
||||
this.channel.inputHistory.splice(1, 0, text);
|
||||
}
|
||||
|
||||
// Limit input history to a 100 entries
|
||||
if (this.channel.inputHistory.length > 100) {
|
||||
this.channel.inputHistory.pop();
|
||||
}
|
||||
|
||||
if (text[0] === "/") {
|
||||
const args = text.substr(1).split(" ");
|
||||
const cmd = args.shift().toLowerCase();
|
||||
|
||||
if (commands.hasOwnProperty(cmd) && commands[cmd].input(args)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
socket.emit("input", {target, text});
|
||||
},
|
||||
openFileUpload() {
|
||||
this.$refs.uploadInput.click();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
200
client/components/ChatUserList.vue
Normal file
200
client/components/ChatUserList.vue
Normal file
@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<aside
|
||||
ref="userlist"
|
||||
class="userlist"
|
||||
@mouseleave="removeHoverUser">
|
||||
<div class="count">
|
||||
<input
|
||||
ref="input"
|
||||
:value="userSearchInput"
|
||||
:placeholder="channel.users.length + ' user' + (channel.users.length === 1 ? '' : 's')"
|
||||
type="search"
|
||||
class="search"
|
||||
aria-label="Search among the user list"
|
||||
tabindex="-1"
|
||||
@input="setUserSearchInput"
|
||||
@keydown.up="navigateUserList($event, -1)"
|
||||
@keydown.down="navigateUserList($event, 1)"
|
||||
@keydown.page-up="navigateUserList($event, -10)"
|
||||
@keydown.page-down="navigateUserList($event, 10)"
|
||||
@keydown.enter="selectUser">
|
||||
</div>
|
||||
<div class="names">
|
||||
<div
|
||||
v-for="(users, mode) in groupedUsers"
|
||||
:key="mode"
|
||||
:class="['user-mode', getModeClass(mode)]">
|
||||
<template v-if="userSearchInput.length > 0">
|
||||
<UsernameFiltered
|
||||
v-for="user in users"
|
||||
:key="user.original.nick"
|
||||
:on-hover="hoverUser"
|
||||
:active="user.original === activeUser"
|
||||
:user="user" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<Username
|
||||
v-for="user in users"
|
||||
:key="user.nick"
|
||||
:on-hover="hoverUser"
|
||||
:active="user === activeUser"
|
||||
:user="user" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const fuzzy = require("fuzzy");
|
||||
import Username from "./Username.vue";
|
||||
import UsernameFiltered from "./UsernameFiltered.vue";
|
||||
|
||||
const modes = {
|
||||
"~": "owner",
|
||||
"&": "admin",
|
||||
"!": "admin",
|
||||
"@": "op",
|
||||
"%": "half-op",
|
||||
"+": "voice",
|
||||
"": "normal",
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "ChatUserList",
|
||||
components: {
|
||||
Username,
|
||||
UsernameFiltered,
|
||||
},
|
||||
props: {
|
||||
channel: Object,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
userSearchInput: "",
|
||||
activeUser: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
// filteredUsers is computed, to avoid unnecessary filtering
|
||||
// as it is shared between filtering and keybindings.
|
||||
filteredUsers() {
|
||||
return fuzzy.filter(
|
||||
this.userSearchInput,
|
||||
this.channel.users,
|
||||
{
|
||||
pre: "<b>",
|
||||
post: "</b>",
|
||||
extract: (u) => u.nick,
|
||||
}
|
||||
);
|
||||
},
|
||||
groupedUsers() {
|
||||
const groups = {};
|
||||
|
||||
if (this.userSearchInput) {
|
||||
const result = this.filteredUsers;
|
||||
|
||||
for (const user of result) {
|
||||
if (!groups[user.original.mode]) {
|
||||
groups[user.original.mode] = [];
|
||||
}
|
||||
|
||||
groups[user.original.mode].push(user);
|
||||
}
|
||||
} else {
|
||||
for (const user of this.channel.users) {
|
||||
if (!groups[user.mode]) {
|
||||
groups[user.mode] = [user];
|
||||
} else {
|
||||
groups[user.mode].push(user);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
setUserSearchInput(e) {
|
||||
this.userSearchInput = e.target.value;
|
||||
},
|
||||
getModeClass(mode) {
|
||||
return modes[mode];
|
||||
},
|
||||
selectUser() {
|
||||
// Simulate a click on the active user to open the context menu.
|
||||
// Coordinates are provided to position the menu correctly.
|
||||
if (!this.activeUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
const el = this.$refs.userlist.querySelector(".active");
|
||||
const rect = el.getBoundingClientRect();
|
||||
const ev = new MouseEvent("click", {
|
||||
view: window,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: rect.x,
|
||||
clientY: rect.y + rect.height,
|
||||
});
|
||||
el.dispatchEvent(ev);
|
||||
},
|
||||
hoverUser(user) {
|
||||
this.activeUser = user;
|
||||
},
|
||||
removeHoverUser() {
|
||||
this.activeUser = null;
|
||||
},
|
||||
navigateUserList(event, direction) {
|
||||
// Prevent propagation to stop global keybind handler from capturing pagedown/pageup
|
||||
// and redirecting it to the message list container for scrolling
|
||||
event.stopImmediatePropagation();
|
||||
|
||||
let users = this.channel.users;
|
||||
|
||||
// Only using filteredUsers when we have to avoids filtering when it's not needed
|
||||
if (this.userSearchInput) {
|
||||
users = this.filteredUsers.map((result) => result.original);
|
||||
}
|
||||
|
||||
// Bail out if there's no users to select
|
||||
if (!users.length) {
|
||||
this.activeUser = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let currentIndex = users.indexOf(this.activeUser);
|
||||
|
||||
// If there's no active user select the first or last one depending on direction
|
||||
if (!this.activeUser || currentIndex === -1) {
|
||||
this.activeUser = direction ? users[0] : users[users.length - 1];
|
||||
this.scrollToActiveUser();
|
||||
return;
|
||||
}
|
||||
|
||||
currentIndex += direction;
|
||||
|
||||
// Wrap around the list if necessary. Normaly each loop iterates once at most,
|
||||
// but might iterate more often if pgup or pgdown are used in a very short user list
|
||||
while (currentIndex < 0) {
|
||||
currentIndex += users.length;
|
||||
}
|
||||
|
||||
while (currentIndex > users.length - 1) {
|
||||
currentIndex -= users.length;
|
||||
}
|
||||
|
||||
this.activeUser = users[currentIndex];
|
||||
this.scrollToActiveUser();
|
||||
},
|
||||
scrollToActiveUser() {
|
||||
// Scroll the list if needed after the active class is applied
|
||||
this.$nextTick(() => {
|
||||
const el = this.$refs.userlist.querySelector(".active");
|
||||
el.scrollIntoView({block: "nearest", inline: "nearest"});
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
56
client/components/DateMarker.vue
Normal file
56
client/components/DateMarker.vue
Normal file
@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div
|
||||
:aria-label="localeDate"
|
||||
class="date-marker-container tooltipped tooltipped-s">
|
||||
<div class="date-marker">
|
||||
<span
|
||||
:data-label="friendlyDate()"
|
||||
class="date-marker-text" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const moment = require("moment");
|
||||
|
||||
export default {
|
||||
name: "DateMarker",
|
||||
props: {
|
||||
message: Object,
|
||||
},
|
||||
computed: {
|
||||
localeDate() {
|
||||
return moment(this.message.time).format("D MMMM YYYY");
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (this.hoursPassed() < 48) {
|
||||
this.$root.$on("daychange", this.dayChange);
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$root.$off("daychange", this.dayChange);
|
||||
},
|
||||
methods: {
|
||||
hoursPassed() {
|
||||
return (Date.now() - Date.parse(this.message.time)) / 3600000;
|
||||
},
|
||||
dayChange() {
|
||||
this.$forceUpdate();
|
||||
|
||||
if (this.hoursPassed() >= 48) {
|
||||
this.$root.$off("daychange", this.dayChange);
|
||||
}
|
||||
},
|
||||
friendlyDate() {
|
||||
// See http://momentjs.com/docs/#/displaying/calendar-time/
|
||||
return moment(this.message.time).calendar(null, {
|
||||
sameDay: "[Today]",
|
||||
lastDay: "[Yesterday]",
|
||||
lastWeek: "D MMMM YYYY",
|
||||
sameElse: "D MMMM YYYY",
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
80
client/components/JoinChannel.vue
Normal file
80
client/components/JoinChannel.vue
Normal file
@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<form
|
||||
:id="'join-channel-' + channel.id"
|
||||
class="join-form"
|
||||
method="post"
|
||||
action=""
|
||||
autocomplete="off"
|
||||
@keydown.esc.prevent="$emit('toggleJoinChannel')"
|
||||
@submit.prevent="onSubmit">
|
||||
<input
|
||||
v-model="inputChannel"
|
||||
v-focus
|
||||
type="text"
|
||||
class="input"
|
||||
name="channel"
|
||||
placeholder="Channel"
|
||||
pattern="[^\s]+"
|
||||
maxlength="200"
|
||||
title="The channel name may not contain spaces"
|
||||
required>
|
||||
<input
|
||||
v-model="inputPassword"
|
||||
type="password"
|
||||
class="input"
|
||||
name="key"
|
||||
placeholder="Password (optional)"
|
||||
pattern="[^\s]+"
|
||||
maxlength="200"
|
||||
title="The channel password may not contain spaces"
|
||||
autocomplete="new-password">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-small">Join</button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import socket from "../js/socket";
|
||||
|
||||
export default {
|
||||
name: "JoinChannel",
|
||||
directives: {
|
||||
focus: {
|
||||
inserted(el) {
|
||||
el.focus();
|
||||
},
|
||||
},
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
channel: Object,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
inputChannel: "",
|
||||
inputPassword: "",
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
onSubmit() {
|
||||
const channelToFind = this.inputChannel.toLowerCase();
|
||||
const existingChannel = this.network.channels.find((c) => c.name.toLowerCase() === channelToFind);
|
||||
|
||||
if (existingChannel) {
|
||||
const $ = require("jquery");
|
||||
$(`#sidebar .chan[data-id="${existingChannel.id}"]`).trigger("click");
|
||||
} else {
|
||||
socket.emit("input", {
|
||||
text: `/join ${this.inputChannel} ${this.inputPassword}`,
|
||||
target: this.channel.id,
|
||||
});
|
||||
}
|
||||
|
||||
this.inputChannel = "";
|
||||
this.inputPassword = "";
|
||||
this.$emit("toggleJoinChannel");
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
231
client/components/LinkPreview.vue
Normal file
231
client/components/LinkPreview.vue
Normal file
@ -0,0 +1,231 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="link.shown"
|
||||
v-show="link.canDisplay"
|
||||
ref="container"
|
||||
class="preview">
|
||||
<div
|
||||
ref="content"
|
||||
:class="['toggle-content', 'toggle-type-' + link.type, { opened: isContentShown }]">
|
||||
<template v-if="link.type === 'link'">
|
||||
<a
|
||||
v-if="link.thumb"
|
||||
:href="link.link"
|
||||
class="toggle-thumbnail"
|
||||
target="_blank"
|
||||
rel="noopener">
|
||||
<img
|
||||
:src="link.thumb"
|
||||
decoding="async"
|
||||
alt=""
|
||||
class="thumb"
|
||||
@error="onThumbnailError"
|
||||
@abort="onThumbnailError"
|
||||
@load="onPreviewReady">
|
||||
</a>
|
||||
<div class="toggle-text">
|
||||
<div class="head">
|
||||
<div class="overflowable">
|
||||
<a
|
||||
:href="link.link"
|
||||
:title="link.head"
|
||||
target="_blank"
|
||||
rel="noopener">{{ link.head }}</a>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="showMoreButton"
|
||||
:aria-expanded="isContentShown"
|
||||
:aria-label="moreButtonLabel"
|
||||
class="more"
|
||||
@click="onMoreClick"><span class="more-caret" /></button>
|
||||
</div>
|
||||
|
||||
<div class="body overflowable">
|
||||
<a
|
||||
:href="link.link"
|
||||
:title="link.body"
|
||||
target="_blank"
|
||||
rel="noopener">{{ link.body }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="link.type === 'image'">
|
||||
<a
|
||||
:href="link.link"
|
||||
class="toggle-thumbnail"
|
||||
target="_blank"
|
||||
rel="noopener">
|
||||
<img
|
||||
:src="link.thumb"
|
||||
decoding="async"
|
||||
alt=""
|
||||
@load="onPreviewReady">
|
||||
</a>
|
||||
</template>
|
||||
<template v-else-if="link.type === 'video'">
|
||||
<video
|
||||
preload="metadata"
|
||||
controls
|
||||
@canplay="onPreviewReady">
|
||||
<source
|
||||
:src="link.media"
|
||||
:type="link.mediaType">
|
||||
</video>
|
||||
</template>
|
||||
<template v-else-if="link.type === 'audio'">
|
||||
<audio
|
||||
controls
|
||||
preload="metadata"
|
||||
@canplay="onPreviewReady">
|
||||
<source
|
||||
:src="link.media"
|
||||
:type="link.mediaType">
|
||||
</audio>
|
||||
</template>
|
||||
<template v-else-if="link.type === 'error'">
|
||||
<em v-if="link.error === 'image-too-big'">
|
||||
This image is larger than {{ link.maxSize | friendlysize }} and cannot be
|
||||
previewed.
|
||||
<a
|
||||
:href="link.link"
|
||||
target="_blank"
|
||||
rel="noopener">Click here</a>
|
||||
to open it in a new window.
|
||||
</em>
|
||||
<template v-else-if="link.error === 'message'">
|
||||
<div>
|
||||
<em>
|
||||
A preview could not be loaded.
|
||||
<a
|
||||
:href="link.link"
|
||||
target="_blank"
|
||||
rel="noopener">Click here</a>
|
||||
to open it in a new window.
|
||||
</em>
|
||||
<br>
|
||||
<pre class="prefetch-error">{{ link.message }}</pre>
|
||||
</div>
|
||||
|
||||
<button
|
||||
:aria-expanded="isContentShown"
|
||||
:aria-label="moreButtonLabel"
|
||||
class="more"
|
||||
@click="onMoreClick"><span class="more-caret" /></button>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "LinkPreview",
|
||||
props: {
|
||||
link: Object,
|
||||
keepScrollPosition: Function,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showMoreButton: false,
|
||||
isContentShown: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
moreButtonLabel() {
|
||||
return this.isContentShown ? "Less" : "More";
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
"link.type"() {
|
||||
this.updateShownState();
|
||||
this.onPreviewUpdate();
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.updateShownState();
|
||||
},
|
||||
mounted() {
|
||||
this.$root.$on("resize", this.handleResize);
|
||||
|
||||
this.onPreviewUpdate();
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$root.$off("resize", this.handleResize);
|
||||
},
|
||||
destroyed() {
|
||||
// Let this preview go through load/canplay events again,
|
||||
// Otherwise the browser can cause a resize on video elements
|
||||
this.link.canDisplay = false;
|
||||
},
|
||||
methods: {
|
||||
onPreviewUpdate() {
|
||||
// Don't display previews while they are loading on the server
|
||||
if (this.link.type === "loading") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Error don't have any media to render
|
||||
if (this.link.type === "error") {
|
||||
this.onPreviewReady();
|
||||
}
|
||||
|
||||
// If link doesn't have a thumbnail, render it
|
||||
if (this.link.type === "link" && !this.link.thumb) {
|
||||
this.onPreviewReady();
|
||||
}
|
||||
},
|
||||
onPreviewReady() {
|
||||
this.$set(this.link, "canDisplay", true);
|
||||
|
||||
this.keepScrollPosition();
|
||||
|
||||
if (this.link.type !== "link") {
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleResize();
|
||||
},
|
||||
onThumbnailError() {
|
||||
// If thumbnail fails to load, hide it and show the preview without it
|
||||
this.link.thumb = "";
|
||||
this.onPreviewReady();
|
||||
},
|
||||
onMoreClick() {
|
||||
this.isContentShown = !this.isContentShown;
|
||||
this.keepScrollPosition();
|
||||
},
|
||||
handleResize() {
|
||||
this.$nextTick(() => {
|
||||
if (!this.$refs.content) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.showMoreButton = this.$refs.content.offsetWidth >= this.$refs.container.offsetWidth;
|
||||
});
|
||||
},
|
||||
updateShownState() {
|
||||
let defaultState = true;
|
||||
|
||||
switch (this.link.type) {
|
||||
case "error":
|
||||
defaultState = this.link.error === "image-too-big" ? this.$root.settings.media : this.$root.settings.links;
|
||||
break;
|
||||
|
||||
case "loading":
|
||||
defaultState = false;
|
||||
break;
|
||||
|
||||
case "link":
|
||||
defaultState = this.$root.settings.links;
|
||||
break;
|
||||
|
||||
default:
|
||||
defaultState = this.$root.settings.media;
|
||||
}
|
||||
|
||||
this.link.shown = this.link.shown && defaultState;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
22
client/components/LinkPreviewToggle.vue
Normal file
22
client/components/LinkPreviewToggle.vue
Normal file
@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<button
|
||||
v-if="link.type !== 'loading'"
|
||||
:class="['toggle-button', 'toggle-preview', { opened: link.shown }]"
|
||||
@click="onClick" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "LinkPreviewToggle",
|
||||
props: {
|
||||
link: Object,
|
||||
},
|
||||
methods: {
|
||||
onClick() {
|
||||
this.link.shown = !this.link.shown;
|
||||
|
||||
this.$parent.$emit("linkPreviewToggle", this.link, this.$parent.message);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
110
client/components/Message.vue
Normal file
110
client/components/Message.vue
Normal file
@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div
|
||||
:id="'msg-' + message.id"
|
||||
:class="['msg', message.type, {self: message.self, highlight: message.highlight}]"
|
||||
:data-from="message.from && message.from.nick">
|
||||
<span
|
||||
:aria-label="message.time | localetime"
|
||||
class="time tooltipped tooltipped-e">{{ messageTime }} </span>
|
||||
<template v-if="message.type === 'unhandled'">
|
||||
<span class="from">[{{ message.command }}]</span>
|
||||
<span class="content">
|
||||
<span
|
||||
v-for="(param, id) in message.params"
|
||||
:key="id">{{ param }} </span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="isAction()">
|
||||
<span class="from"><span class="only-copy">*** </span></span>
|
||||
<Component
|
||||
:is="messageComponent"
|
||||
:network="network"
|
||||
:message="message" />
|
||||
</template>
|
||||
<template v-else-if="message.type === 'action'">
|
||||
<span class="from"><span class="only-copy">* </span></span>
|
||||
<span class="content">
|
||||
<Username :user="message.from" /> <ParsedMessage
|
||||
:network="network"
|
||||
:message="message" />
|
||||
<LinkPreview
|
||||
v-for="preview in message.previews"
|
||||
:key="preview.link"
|
||||
:keep-scroll-position="keepScrollPosition"
|
||||
:link="preview" />
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span
|
||||
v-if="message.type === 'message'"
|
||||
class="from">
|
||||
<template v-if="message.from && message.from.nick">
|
||||
<span class="only-copy"><</span>
|
||||
<Username :user="message.from" />
|
||||
<span class="only-copy">> </span>
|
||||
</template>
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="from">
|
||||
<template v-if="message.from && message.from.nick">
|
||||
<span class="only-copy">-</span>
|
||||
<Username :user="message.from" />
|
||||
<span class="only-copy">- </span>
|
||||
</template>
|
||||
</span>
|
||||
<span class="content">
|
||||
<ParsedMessage
|
||||
:network="network"
|
||||
:message="message" />
|
||||
<LinkPreview
|
||||
v-for="preview in message.previews"
|
||||
:key="preview.link"
|
||||
:keep-scroll-position="keepScrollPosition"
|
||||
:link="preview" />
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Username from "./Username.vue";
|
||||
import LinkPreview from "./LinkPreview.vue";
|
||||
import ParsedMessage from "./ParsedMessage.vue";
|
||||
import MessageTypes from "./MessageTypes";
|
||||
|
||||
const moment = require("moment");
|
||||
const constants = require("../js/constants");
|
||||
|
||||
MessageTypes.ParsedMessage = ParsedMessage;
|
||||
MessageTypes.LinkPreview = LinkPreview;
|
||||
MessageTypes.Username = Username;
|
||||
|
||||
export default {
|
||||
name: "Message",
|
||||
components: MessageTypes,
|
||||
props: {
|
||||
message: Object,
|
||||
network: Object,
|
||||
keepScrollPosition: Function,
|
||||
},
|
||||
computed: {
|
||||
messageTime() {
|
||||
const format = this.$root.settings.showSeconds ? constants.timeFormats.msgWithSeconds : constants.timeFormats.msgDefault;
|
||||
|
||||
return moment(this.message.time).format(format);
|
||||
},
|
||||
messageComponent() {
|
||||
return "message-" + this.message.type;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
require("../js/renderPreview");
|
||||
},
|
||||
methods: {
|
||||
isAction() {
|
||||
return typeof MessageTypes["message-" + this.message.type] !== "undefined";
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
102
client/components/MessageCondensed.vue
Normal file
102
client/components/MessageCondensed.vue
Normal file
@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div :class="[ 'msg', 'condensed', { closed: isCollapsed } ]">
|
||||
<div class="condensed-summary">
|
||||
<span class="time" />
|
||||
<span class="from" />
|
||||
<span
|
||||
class="content"
|
||||
@click="onCollapseClick">{{ condensedText }}<button
|
||||
class="toggle-button"
|
||||
aria-label="Toggle status messages" /></span>
|
||||
</div>
|
||||
<Message
|
||||
v-for="message in messages"
|
||||
:key="message.id"
|
||||
:network="network"
|
||||
:message="message" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const constants = require("../js/constants");
|
||||
import Message from "./Message.vue";
|
||||
|
||||
export default {
|
||||
name: "MessageCondensed",
|
||||
components: {
|
||||
Message,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
messages: Array,
|
||||
keepScrollPosition: Function,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isCollapsed: true,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
condensedText() {
|
||||
const obj = {};
|
||||
|
||||
constants.condensedTypes.forEach((type) => {
|
||||
obj[type] = 0;
|
||||
});
|
||||
|
||||
for (const message of this.messages) {
|
||||
obj[message.type]++;
|
||||
}
|
||||
|
||||
// Count quits as parts in condensed messages to reduce information density
|
||||
obj.part += obj.quit;
|
||||
|
||||
const strings = [];
|
||||
constants.condensedTypes.forEach((type) => {
|
||||
if (obj[type]) {
|
||||
switch (type) {
|
||||
case "away":
|
||||
strings.push(obj[type] + (obj[type] > 1 ? " users have gone away" : " user has gone away"));
|
||||
break;
|
||||
case "back":
|
||||
strings.push(obj[type] + (obj[type] > 1 ? " users have come back" : " user has come back"));
|
||||
break;
|
||||
case "chghost":
|
||||
strings.push(obj[type] + (obj[type] > 1 ? " users have changed hostname" : " user has changed hostname"));
|
||||
break;
|
||||
case "join":
|
||||
strings.push(obj[type] + (obj[type] > 1 ? " users have joined" : " user has joined"));
|
||||
break;
|
||||
case "part":
|
||||
strings.push(obj[type] + (obj[type] > 1 ? " users have left" : " user has left"));
|
||||
break;
|
||||
case "nick":
|
||||
strings.push(obj[type] + (obj[type] > 1 ? " users have changed nick" : " user has changed nick"));
|
||||
break;
|
||||
case "kick":
|
||||
strings.push(obj[type] + (obj[type] > 1 ? " users were kicked" : " user was kicked"));
|
||||
break;
|
||||
case "mode":
|
||||
strings.push(obj[type] + (obj[type] > 1 ? " modes were set" : " mode was set"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let text = strings.pop();
|
||||
|
||||
if (strings.length) {
|
||||
text = strings.join(", ") + ", and " + text;
|
||||
}
|
||||
|
||||
return text;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onCollapseClick() {
|
||||
this.isCollapsed = !this.isCollapsed;
|
||||
this.keepScrollPosition();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
306
client/components/MessageList.vue
Normal file
306
client/components/MessageList.vue
Normal file
@ -0,0 +1,306 @@
|
||||
<template>
|
||||
<div
|
||||
ref="chat"
|
||||
class="chat"
|
||||
tabindex="-1">
|
||||
<div :class="['show-more', { show: channel.moreHistoryAvailable }]">
|
||||
<button
|
||||
ref="loadMoreButton"
|
||||
:disabled="channel.historyLoading || !$root.isConnected"
|
||||
class="btn"
|
||||
@click="onShowMoreClick">
|
||||
<span v-if="channel.historyLoading">Loading…</span>
|
||||
<span v-else>Show older messages</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="messages"
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
aria-relevant="additions"
|
||||
@copy="onCopy">
|
||||
<template v-for="(message, id) in condensedMessages">
|
||||
<DateMarker
|
||||
v-if="shouldDisplayDateMarker(message, id)"
|
||||
:key="message.id + '-date'"
|
||||
:message="message" />
|
||||
<div
|
||||
v-if="shouldDisplayUnreadMarker(message.id)"
|
||||
:key="message.id + '-unread'"
|
||||
class="unread-marker">
|
||||
<span class="unread-marker-text" />
|
||||
</div>
|
||||
|
||||
<MessageCondensed
|
||||
v-if="message.type === 'condensed'"
|
||||
:key="message.id"
|
||||
:network="network"
|
||||
:keep-scroll-position="keepScrollPosition"
|
||||
:messages="message.messages" />
|
||||
<Message
|
||||
v-else
|
||||
:key="message.id"
|
||||
:network="network"
|
||||
:message="message"
|
||||
:keep-scroll-position="keepScrollPosition"
|
||||
@linkPreviewToggle="onLinkPreviewToggle" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
require("intersection-observer");
|
||||
|
||||
const constants = require("../js/constants");
|
||||
const clipboard = require("../js/clipboard");
|
||||
import socket from "../js/socket";
|
||||
import Message from "./Message.vue";
|
||||
import MessageCondensed from "./MessageCondensed.vue";
|
||||
import DateMarker from "./DateMarker.vue";
|
||||
|
||||
export default {
|
||||
name: "MessageList",
|
||||
components: {
|
||||
Message,
|
||||
MessageCondensed,
|
||||
DateMarker,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
channel: Object,
|
||||
},
|
||||
computed: {
|
||||
condensedMessages() {
|
||||
if (this.channel.type !== "channel") {
|
||||
return this.channel.messages;
|
||||
}
|
||||
|
||||
// If actions are hidden, just return a message list with them excluded
|
||||
if (this.$root.settings.statusMessages === "hidden") {
|
||||
return this.channel.messages.filter((message) => !constants.condensedTypes.includes(message.type));
|
||||
}
|
||||
|
||||
// If actions are not condensed, just return raw message list
|
||||
if (this.$root.settings.statusMessages !== "condensed") {
|
||||
return this.channel.messages;
|
||||
}
|
||||
|
||||
const condensed = [];
|
||||
let lastCondensedContainer = null;
|
||||
|
||||
for (const message of this.channel.messages) {
|
||||
// If this message is not condensable, or its an action affecting our user,
|
||||
// then just append the message to container and be done with it
|
||||
if (message.self || message.highlight || !constants.condensedTypes.includes(message.type)) {
|
||||
lastCondensedContainer = null;
|
||||
|
||||
condensed.push(message);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lastCondensedContainer === null) {
|
||||
lastCondensedContainer = {
|
||||
time: message.time,
|
||||
type: "condensed",
|
||||
messages: [],
|
||||
};
|
||||
|
||||
condensed.push(lastCondensedContainer);
|
||||
}
|
||||
|
||||
lastCondensedContainer.messages.push(message);
|
||||
|
||||
// Set id of the condensed container to last message id,
|
||||
// which is required for the unread marker to work correctly
|
||||
lastCondensedContainer.id = message.id;
|
||||
|
||||
// If this message is the unread boundary, create a split condensed container
|
||||
if (message.id === this.channel.firstUnread) {
|
||||
lastCondensedContainer = null;
|
||||
}
|
||||
}
|
||||
|
||||
return condensed;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
"channel.id"() {
|
||||
this.channel.scrolledToBottom = true;
|
||||
|
||||
// Re-add the intersection observer to trigger the check again on channel switch
|
||||
// Otherwise if last channel had the button visible, switching to a new channel won't trigger the history
|
||||
if (this.historyObserver) {
|
||||
this.historyObserver.unobserve(this.$refs.loadMoreButton);
|
||||
this.historyObserver.observe(this.$refs.loadMoreButton);
|
||||
}
|
||||
},
|
||||
"channel.messages"() {
|
||||
this.keepScrollPosition();
|
||||
},
|
||||
"channel.pendingMessage"() {
|
||||
this.$nextTick(() => {
|
||||
// Keep the scroll stuck when input gets resized while typing
|
||||
this.keepScrollPosition();
|
||||
});
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.$nextTick(() => {
|
||||
if (!this.$refs.chat) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.IntersectionObserver) {
|
||||
this.historyObserver = new window.IntersectionObserver(this.onLoadButtonObserved, {
|
||||
root: this.$refs.chat,
|
||||
});
|
||||
}
|
||||
|
||||
this.jumpToBottom();
|
||||
});
|
||||
},
|
||||
mounted() {
|
||||
this.$refs.chat.addEventListener("scroll", this.handleScroll, {passive: true});
|
||||
|
||||
this.$root.$on("resize", this.handleResize);
|
||||
|
||||
this.$nextTick(() => {
|
||||
if (this.historyObserver) {
|
||||
this.historyObserver.observe(this.$refs.loadMoreButton);
|
||||
}
|
||||
});
|
||||
},
|
||||
beforeUpdate() {
|
||||
this.unreadMarkerShown = false;
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$root.$off("resize", this.handleResize);
|
||||
this.$refs.chat.removeEventListener("scroll", this.handleScroll);
|
||||
},
|
||||
destroyed() {
|
||||
if (this.historyObserver) {
|
||||
this.historyObserver.disconnect();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
shouldDisplayDateMarker(message, id) {
|
||||
const previousMessage = this.condensedMessages[id - 1];
|
||||
|
||||
if (!previousMessage) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (new Date(previousMessage.time)).getDay() !== (new Date(message.time)).getDay();
|
||||
},
|
||||
shouldDisplayUnreadMarker(id) {
|
||||
if (!this.unreadMarkerShown && id > this.channel.firstUnread) {
|
||||
this.unreadMarkerShown = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
onCopy() {
|
||||
clipboard(this.$el);
|
||||
},
|
||||
onLinkPreviewToggle(preview, message) {
|
||||
this.keepScrollPosition();
|
||||
|
||||
// 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", {
|
||||
target: this.channel.id,
|
||||
msgId: message.id,
|
||||
link: preview.link,
|
||||
shown: preview.shown,
|
||||
});
|
||||
},
|
||||
onShowMoreClick() {
|
||||
let lastMessage = this.channel.messages[0];
|
||||
lastMessage = lastMessage ? lastMessage.id : -1;
|
||||
|
||||
this.channel.historyLoading = true;
|
||||
|
||||
socket.emit("more", {
|
||||
target: this.channel.id,
|
||||
lastId: lastMessage,
|
||||
});
|
||||
},
|
||||
onLoadButtonObserved(entries) {
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.isIntersecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.onShowMoreClick();
|
||||
});
|
||||
},
|
||||
keepScrollPosition() {
|
||||
// If we are already waiting for the next tick to force scroll position,
|
||||
// we have no reason to perform more checks and set it again in the next tick
|
||||
if (this.isWaitingForNextTick) {
|
||||
return;
|
||||
}
|
||||
|
||||
const el = this.$refs.chat;
|
||||
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.channel.scrolledToBottom) {
|
||||
if (this.channel.historyLoading) {
|
||||
const heightOld = el.scrollHeight - el.scrollTop;
|
||||
|
||||
this.isWaitingForNextTick = true;
|
||||
this.$nextTick(() => {
|
||||
this.isWaitingForNextTick = false;
|
||||
this.skipNextScrollEvent = true;
|
||||
el.scrollTop = el.scrollHeight - heightOld;
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.isWaitingForNextTick = true;
|
||||
this.$nextTick(() => {
|
||||
this.isWaitingForNextTick = false;
|
||||
this.jumpToBottom();
|
||||
});
|
||||
},
|
||||
handleScroll() {
|
||||
// Setting scrollTop also triggers scroll event
|
||||
// We don't want to perform calculations for that
|
||||
if (this.skipNextScrollEvent) {
|
||||
this.skipNextScrollEvent = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const el = this.$refs.chat;
|
||||
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.channel.scrolledToBottom = el.scrollHeight - el.scrollTop - el.offsetHeight <= 30;
|
||||
},
|
||||
handleResize() {
|
||||
// Keep message list scrolled to bottom on resize
|
||||
if (this.channel.scrolledToBottom) {
|
||||
this.jumpToBottom();
|
||||
}
|
||||
},
|
||||
jumpToBottom() {
|
||||
this.skipNextScrollEvent = true;
|
||||
this.channel.scrolledToBottom = true;
|
||||
|
||||
const el = this.$refs.chat;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
32
client/components/MessageTypes/away.vue
Normal file
32
client/components/MessageTypes/away.vue
Normal file
@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<span class="content">
|
||||
<ParsedMessage
|
||||
v-if="message.self"
|
||||
:network="network"
|
||||
:message="message" />
|
||||
<template v-else>
|
||||
<Username :user="message.from" />
|
||||
is away
|
||||
<i class="away-message">(<ParsedMessage
|
||||
:network="network"
|
||||
:message="message" />)</i>
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
name: "MessageTypeAway",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
},
|
||||
};
|
||||
</script>
|
29
client/components/MessageTypes/back.vue
Normal file
29
client/components/MessageTypes/back.vue
Normal file
@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<span class="content">
|
||||
<ParsedMessage
|
||||
v-if="message.self"
|
||||
:network="network"
|
||||
:message="message" />
|
||||
<template v-else>
|
||||
<Username :user="message.from" />
|
||||
is back
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
name: "MessageTypeBack",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
},
|
||||
};
|
||||
</script>
|
23
client/components/MessageTypes/chghost.vue
Normal file
23
client/components/MessageTypes/chghost.vue
Normal file
@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<span class="content">
|
||||
<Username :user="message.from" />
|
||||
has changed
|
||||
<span v-if="message.new_ident">username to <b>{{ message.new_ident }}</b></span>
|
||||
<span v-if="message.new_host">hostname to <i class="hostmask">{{ message.new_host }}</i></span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
name: "MessageTypeChangeHost",
|
||||
components: {
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
},
|
||||
};
|
||||
</script>
|
23
client/components/MessageTypes/ctcp.vue
Normal file
23
client/components/MessageTypes/ctcp.vue
Normal file
@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<span class="content">
|
||||
<Username :user="message.from" /> 
|
||||
<span class="ctcp-message"><ParsedMessage :text="message.ctcpMessage" /></span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
name: "MessageTypeCTCP",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
},
|
||||
};
|
||||
</script>
|
24
client/components/MessageTypes/ctcp_request.vue
Normal file
24
client/components/MessageTypes/ctcp_request.vue
Normal file
@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<span class="content">
|
||||
<Username :user="message.from" />
|
||||
sent a <abbr title="Client-to-client protocol">CTCP</abbr> request:
|
||||
<span class="ctcp-message"><ParsedMessage :text="message.ctcpMessage" /></span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
name: "MessageTypeRequestCTCP",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
},
|
||||
};
|
||||
</script>
|
13
client/components/MessageTypes/index.js
Normal file
13
client/components/MessageTypes/index.js
Normal file
@ -0,0 +1,13 @@
|
||||
"use strict";
|
||||
|
||||
// This creates a version of `require()` in the context of the current
|
||||
// directory, so we iterate over its content, which is a map statically built by
|
||||
// Webpack.
|
||||
// Second argument says it's recursive, third makes sure we only load templates.
|
||||
const requireViews = require.context(".", false, /\.vue$/);
|
||||
|
||||
module.exports = requireViews.keys().reduce((acc, path) => {
|
||||
acc["message-" + path.substring(2, path.length - 4)] = requireViews(path).default;
|
||||
|
||||
return acc;
|
||||
}, {});
|
30
client/components/MessageTypes/invite.vue
Normal file
30
client/components/MessageTypes/invite.vue
Normal file
@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<span class="content">
|
||||
<Username :user="message.from" />
|
||||
invited
|
||||
<span v-if="message.invitedYou">you</span>
|
||||
<Username
|
||||
v-else
|
||||
:user="message.target" />
|
||||
to <ParsedMessage
|
||||
:network="network"
|
||||
:text="message.channel" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
name: "MessageTypeInvite",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
},
|
||||
};
|
||||
</script>
|
22
client/components/MessageTypes/join.vue
Normal file
22
client/components/MessageTypes/join.vue
Normal file
@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<span class="content">
|
||||
<Username :user="message.from" />
|
||||
<i class="hostmask"> ({{ message.hostmask }})</i>
|
||||
has joined the channel
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
name: "MessageTypeJoin",
|
||||
components: {
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
},
|
||||
};
|
||||
</script>
|
29
client/components/MessageTypes/kick.vue
Normal file
29
client/components/MessageTypes/kick.vue
Normal file
@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<span class="content">
|
||||
<Username :user="message.from" />
|
||||
has kicked
|
||||
<Username :user="message.target" />
|
||||
<i
|
||||
v-if="message.text"
|
||||
class="part-reason"> (<ParsedMessage
|
||||
:network="network"
|
||||
:message="message" />)</i>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
name: "MessageTypeKick",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
},
|
||||
};
|
||||
</script>
|
24
client/components/MessageTypes/mode.vue
Normal file
24
client/components/MessageTypes/mode.vue
Normal file
@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<span class="content">
|
||||
<Username :user="message.from" />
|
||||
sets mode
|
||||
<ParsedMessage :message="message" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
name: "MessageTypeMode",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
},
|
||||
};
|
||||
</script>
|
39
client/components/MessageTypes/motd.vue
Normal file
39
client/components/MessageTypes/motd.vue
Normal file
@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<span class="content">
|
||||
<span class="text"><ParsedMessage
|
||||
:network="network"
|
||||
:text="cleanText" /></span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
|
||||
export default {
|
||||
name: "MessageTypeMOTD",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
},
|
||||
computed: {
|
||||
cleanText() {
|
||||
let lines = this.message.text.split("\n");
|
||||
|
||||
// If all non-empty lines of the MOTD start with a hyphen (which is common
|
||||
// across MOTDs), remove all the leading hyphens.
|
||||
if (lines.every((line) => line === "" || line[0] === "-")) {
|
||||
lines = lines.map((line) => line.substr(2));
|
||||
}
|
||||
|
||||
// Remove empty lines around the MOTD (but not within it)
|
||||
return lines
|
||||
.map((line) => line.replace(/\s*$/,""))
|
||||
.join("\n")
|
||||
.replace(/^[\r\n]+|[\r\n]+$/g, "");
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
22
client/components/MessageTypes/nick.vue
Normal file
22
client/components/MessageTypes/nick.vue
Normal file
@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<span class="content">
|
||||
<Username :user="message.from" />
|
||||
is now known as
|
||||
<Username :user="{nick: message.new_nick, mode: message.from.mode}" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
name: "MessageTypeNick",
|
||||
components: {
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
},
|
||||
};
|
||||
</script>
|
27
client/components/MessageTypes/part.vue
Normal file
27
client/components/MessageTypes/part.vue
Normal file
@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<span class="content">
|
||||
<Username :user="message.from" />
|
||||
<i class="hostmask"> ({{ message.hostmask }})</i> has left the channel <i
|
||||
v-if="message.text"
|
||||
class="part-reason">(<ParsedMessage
|
||||
:network="network"
|
||||
:message="message" />)</i>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
name: "MessageTypePart",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
},
|
||||
};
|
||||
</script>
|
27
client/components/MessageTypes/quit.vue
Normal file
27
client/components/MessageTypes/quit.vue
Normal file
@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<span class="content">
|
||||
<Username :user="message.from" />
|
||||
<i class="hostmask"> ({{ message.hostmask }})</i> has quit <i
|
||||
v-if="message.text"
|
||||
class="quit-reason">(<ParsedMessage
|
||||
:network="network"
|
||||
:message="message" />)</i>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
name: "MessageTypeQuit",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
},
|
||||
};
|
||||
</script>
|
28
client/components/MessageTypes/topic.vue
Normal file
28
client/components/MessageTypes/topic.vue
Normal file
@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<span class="content">
|
||||
<template v-if="message.from && message.from.nick"><Username :user="message.from" /> has changed the topic to: </template>
|
||||
<template v-else>The topic is: </template>
|
||||
<span
|
||||
v-if="message.text"
|
||||
class="new-topic"><ParsedMessage
|
||||
:network="network"
|
||||
:message="message" /></span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
name: "MessageTypeTopic",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
},
|
||||
};
|
||||
</script>
|
22
client/components/MessageTypes/topic_set_by.vue
Normal file
22
client/components/MessageTypes/topic_set_by.vue
Normal file
@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<span class="content">
|
||||
Topic set by
|
||||
<Username :user="message.from" />
|
||||
on {{ message.when | localetime }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
name: "MessageTypeTopicSetBy",
|
||||
components: {
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
},
|
||||
};
|
||||
</script>
|
119
client/components/MessageTypes/whois.vue
Normal file
119
client/components/MessageTypes/whois.vue
Normal file
@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<span class="content">
|
||||
<p>
|
||||
<Username :user="{nick: message.whois.nick}" />
|
||||
<span v-if="message.whois.whowas"> is offline, last information:</span>
|
||||
</p>
|
||||
|
||||
<dl class="whois">
|
||||
<template v-if="message.whois.account">
|
||||
<dt>Logged in as:</dt>
|
||||
<dd>{{ message.whois.account }}</dd>
|
||||
</template>
|
||||
|
||||
<dt>Host mask:</dt>
|
||||
<dd class="hostmask">{{ message.whois.ident }}@{{ message.whois.hostname }}</dd>
|
||||
|
||||
<template v-if="message.whois.actual_hostname">
|
||||
<dt>Actual host:</dt>
|
||||
<dd class="hostmask">
|
||||
<a
|
||||
:href="'https://ipinfo.io/' + message.whois.actual_ip"
|
||||
target="_blank"
|
||||
rel="noopener">{{ message.whois.actual_ip }}</a>
|
||||
<i v-if="message.whois.actual_hostname != message.whois.actual_ip"> ({{ message.whois.actual_hostname }})</i>
|
||||
</dd>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.real_name">
|
||||
<dt>Real name:</dt>
|
||||
<dd><ParsedMessage
|
||||
:network="network"
|
||||
:text="message.whois.real_name" /></dd>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.registered_nick">
|
||||
<dt>Registered nick:</dt>
|
||||
<dd>{{ message.whois.registered_nick }}</dd>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.channels">
|
||||
<dt>Channels:</dt>
|
||||
<dd><ParsedMessage
|
||||
:network="network"
|
||||
:text="message.whois.channels" /></dd>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.modes">
|
||||
<dt>Modes:</dt>
|
||||
<dd>{{ message.whois.modes }}</dd>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.special">
|
||||
<template v-for="special in message.whois.special">
|
||||
<dt :key="special">Special:</dt>
|
||||
<dd :key="special">{{ special }}</dd>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.operator">
|
||||
<dt>Operator:</dt>
|
||||
<dd>{{ message.whois.operator }}</dd>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.helpop">
|
||||
<dt>Available for help:</dt>
|
||||
<dd>Yes</dd>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.bot">
|
||||
<dt>Is a bot:</dt>
|
||||
<dd>Yes</dd>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.away">
|
||||
<dt>Away:</dt>
|
||||
<dd><ParsedMessage
|
||||
:network="network"
|
||||
:text="message.whois.away" /></dd>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.secure">
|
||||
<dt>Secure connection:</dt>
|
||||
<dd>Yes</dd>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.server">
|
||||
<dt>Connected to:</dt>
|
||||
<dd>{{ message.whois.server }} <i>({{ message.whois.server_info }})</i></dd>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.logonTime">
|
||||
<dt>Connected at:</dt>
|
||||
<dd>{{ message.whois.logonTime | localetime }}</dd>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.idle">
|
||||
<dt>Idle since:</dt>
|
||||
<dd>{{ message.whois.idleTime | localetime }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
name: "MessageTypeWhois",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
},
|
||||
};
|
||||
</script>
|
123
client/components/NetworkList.vue
Normal file
123
client/components/NetworkList.vue
Normal file
@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="networks.length === 0"
|
||||
class="empty">
|
||||
You are not connected to any networks yet.
|
||||
</div>
|
||||
<Draggable
|
||||
v-else
|
||||
:list="networks"
|
||||
:options="{ handle: '.lobby', draggable: '.network', ghostClass: 'network-placeholder', disabled: isSortingEnabled }"
|
||||
class="networks"
|
||||
@change="onNetworkSort"
|
||||
@start="onDragStart"
|
||||
@end="onDragEnd">
|
||||
<div
|
||||
v-for="network in networks"
|
||||
:id="'network-' + network.uuid"
|
||||
:key="network.uuid"
|
||||
:class="{
|
||||
collapsed: network.isCollapsed,
|
||||
'not-connected': !network.status.connected,
|
||||
'not-secure': !network.status.secure,
|
||||
}"
|
||||
:data-uuid="network.uuid"
|
||||
:data-nick="network.nick"
|
||||
class="network"
|
||||
role="region">
|
||||
<NetworkLobby
|
||||
:network="network"
|
||||
:active-channel="activeChannel"
|
||||
:is-join-channel-shown="network.isJoinChannelShown"
|
||||
@toggleJoinChannel="network.isJoinChannelShown = !network.isJoinChannelShown" />
|
||||
<JoinChannel
|
||||
v-if="network.isJoinChannelShown"
|
||||
:network="network"
|
||||
:channel="network.channels[0]"
|
||||
@toggleJoinChannel="network.isJoinChannelShown = !network.isJoinChannelShown" />
|
||||
|
||||
<Draggable
|
||||
:options="{ draggable: '.chan', ghostClass: 'chan-placeholder', disabled: isSortingEnabled }"
|
||||
:list="network.channels"
|
||||
class="channels"
|
||||
@change="onChannelSort"
|
||||
@start="onDragStart"
|
||||
@end="onDragEnd">
|
||||
<Channel
|
||||
v-for="(channel, index) in network.channels"
|
||||
v-if="index > 0"
|
||||
:key="channel.id"
|
||||
:channel="channel"
|
||||
:network="network"
|
||||
:active-channel="activeChannel" />
|
||||
</Draggable>
|
||||
</div>
|
||||
</Draggable>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Draggable from "vuedraggable";
|
||||
import NetworkLobby from "./NetworkLobby.vue";
|
||||
import Channel from "./Channel.vue";
|
||||
import JoinChannel from "./JoinChannel.vue";
|
||||
|
||||
import socket from "../js/socket";
|
||||
|
||||
export default {
|
||||
name: "NetworkList",
|
||||
components: {
|
||||
JoinChannel,
|
||||
NetworkLobby,
|
||||
Channel,
|
||||
Draggable,
|
||||
},
|
||||
props: {
|
||||
activeChannel: Object,
|
||||
networks: Array,
|
||||
},
|
||||
computed: {
|
||||
isSortingEnabled() {
|
||||
const isTouch = !!("ontouchstart" in window || (window.DocumentTouch && document instanceof window.DocumentTouch));
|
||||
|
||||
// TODO: Implement a way to sort on touch devices
|
||||
return isTouch;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onDragStart(e) {
|
||||
e.target.classList.add("ui-sortable-helper");
|
||||
},
|
||||
onDragEnd(e) {
|
||||
e.target.classList.remove("ui-sortable-helper");
|
||||
},
|
||||
onNetworkSort(e) {
|
||||
if (!e.moved) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit("sort", {
|
||||
type: "networks",
|
||||
order: this.networks.map((n) => n.uuid),
|
||||
});
|
||||
},
|
||||
onChannelSort(e) {
|
||||
if (!e.moved) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {findChannel} = require("../js/vue");
|
||||
const channel = findChannel(e.moved.element.id);
|
||||
|
||||
if (!channel) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit("sort", {
|
||||
type: "channels",
|
||||
target: channel.network.uuid,
|
||||
order: channel.network.channels.map((c) => c.id),
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
87
client/components/NetworkLobby.vue
Normal file
87
client/components/NetworkLobby.vue
Normal file
@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<ChannelWrapper
|
||||
:network="network"
|
||||
:channel="channel"
|
||||
:active-channel="activeChannel">
|
||||
<button
|
||||
v-if="network.channels.length > 1"
|
||||
:aria-controls="'network-' + network.uuid"
|
||||
:aria-label="getExpandLabel(network)"
|
||||
:aria-expanded="!network.isCollapsed"
|
||||
class="collapse-network"
|
||||
@click.stop="onCollapseClick"><span class="collapse-network-icon" /></button>
|
||||
<span
|
||||
v-else
|
||||
class="collapse-network" />
|
||||
<div class="lobby-wrap">
|
||||
<span
|
||||
:title="channel.name"
|
||||
class="name">{{ channel.name }}</span>
|
||||
<span
|
||||
class="not-secure-tooltip tooltipped tooltipped-w"
|
||||
aria-label="Insecure connection">
|
||||
<span class="not-secure-icon" />
|
||||
</span>
|
||||
<span
|
||||
class="not-connected-tooltip tooltipped tooltipped-w"
|
||||
aria-label="Disconnected">
|
||||
<span class="not-connected-icon" />
|
||||
</span>
|
||||
<span
|
||||
v-if="channel.unread"
|
||||
:class="{ highlight: channel.highlight }"
|
||||
class="badge">{{ channel.unread | roundBadgeNumber }}</span>
|
||||
</div>
|
||||
<span
|
||||
:aria-label="joinChannelLabel"
|
||||
class="add-channel-tooltip tooltipped tooltipped-w tooltipped-no-touch">
|
||||
<button
|
||||
:class="['add-channel', { opened: isJoinChannelShown }]"
|
||||
:aria-controls="'join-channel-' + channel.id"
|
||||
:aria-label="joinChannelLabel"
|
||||
@click.stop="$emit('toggleJoinChannel')" />
|
||||
</span>
|
||||
</ChannelWrapper>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ChannelWrapper from "./ChannelWrapper.vue";
|
||||
const storage = require("../js/localStorage");
|
||||
|
||||
export default {
|
||||
name: "Channel",
|
||||
components: {
|
||||
ChannelWrapper,
|
||||
},
|
||||
props: {
|
||||
activeChannel: Object,
|
||||
network: Object,
|
||||
isJoinChannelShown: Boolean,
|
||||
},
|
||||
computed: {
|
||||
channel() {
|
||||
return this.network.channels[0];
|
||||
},
|
||||
joinChannelLabel() {
|
||||
return this.isJoinChannelShown ? "Cancel" : "Join a channel…";
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onCollapseClick() {
|
||||
const networks = new Set(JSON.parse(storage.get("thelounge.networks.collapsed")));
|
||||
this.network.isCollapsed = !this.network.isCollapsed;
|
||||
|
||||
if (this.network.isCollapsed) {
|
||||
networks.add(this.network.uuid);
|
||||
} else {
|
||||
networks.delete(this.network.uuid);
|
||||
}
|
||||
|
||||
storage.set("thelounge.networks.collapsed", JSON.stringify([...networks]));
|
||||
},
|
||||
getExpandLabel(network) {
|
||||
return network.isCollapsed ? "Expand" : "Collapse";
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
21
client/components/ParsedMessage.vue
Normal file
21
client/components/ParsedMessage.vue
Normal file
@ -0,0 +1,21 @@
|
||||
<script>
|
||||
const parse = require("../js/libs/handlebars/parse");
|
||||
|
||||
export default {
|
||||
name: "ParsedMessage",
|
||||
functional: true,
|
||||
props: {
|
||||
text: String,
|
||||
message: Object,
|
||||
network: Object,
|
||||
},
|
||||
render(createElement, context) {
|
||||
return parse(
|
||||
createElement,
|
||||
typeof context.props.text !== "undefined" ? context.props.text : context.props.message.text,
|
||||
context.props.message,
|
||||
context.props.network
|
||||
);
|
||||
},
|
||||
};
|
||||
</script>
|
30
client/components/Special/ListBans.vue
Normal file
30
client/components/Special/ListBans.vue
Normal file
@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<table class="ban-list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="hostmask">Banned</th>
|
||||
<th class="banned_by">Banned By</th>
|
||||
<th class="banned_at">Banned At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="ban in channel.data"
|
||||
:key="ban.hostmask">
|
||||
<td class="hostmask">{{ ban.hostmask }}</td>
|
||||
<td class="banned_by">{{ ban.banned_by }}</td>
|
||||
<td class="banned_at">{{ ban.banned_at | localetime }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "ListBans",
|
||||
props: {
|
||||
network: Object,
|
||||
channel: Object,
|
||||
},
|
||||
};
|
||||
</script>
|
42
client/components/Special/ListChannels.vue
Normal file
42
client/components/Special/ListChannels.vue
Normal file
@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<span v-if="channel.data.text">{{ channel.data.text }}</span>
|
||||
<table
|
||||
v-else
|
||||
class="channel-list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="channel">Channel</th>
|
||||
<th class="users">Users</th>
|
||||
<th class="topic">Topic</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="chan in channel.data"
|
||||
:key="chan.channel">
|
||||
<td class="channel"><ParsedMessage
|
||||
:network="network"
|
||||
:text="chan.channel" /></td>
|
||||
<td class="users">{{ chan.num_users }}</td>
|
||||
<td class="topic"><ParsedMessage
|
||||
:network="network"
|
||||
:text="chan.topic" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
|
||||
export default {
|
||||
name: "ListChannels",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
channel: Object,
|
||||
},
|
||||
};
|
||||
</script>
|
28
client/components/Special/ListIgnored.vue
Normal file
28
client/components/Special/ListIgnored.vue
Normal file
@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<table class="ignore-list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="hostmask">Hostmask</th>
|
||||
<th class="when">Ignored At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="user in channel.data"
|
||||
:key="user.hostmask">
|
||||
<td class="hostmask">{{ user.hostmask }}</td>
|
||||
<td class="when">{{ user.when | localetime }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "ListIgnored",
|
||||
props: {
|
||||
network: Object,
|
||||
channel: Object,
|
||||
},
|
||||
};
|
||||
</script>
|
23
client/components/Username.vue
Normal file
23
client/components/Username.vue
Normal file
@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<span
|
||||
:class="['user', $options.filters.colorClass(user.nick), { active: active }]"
|
||||
:data-name="user.nick"
|
||||
role="button"
|
||||
v-on="onHover ? { mouseover: hover } : {}">{{ user.mode }}{{ user.nick }}</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "Username",
|
||||
props: {
|
||||
user: Object,
|
||||
active: Boolean,
|
||||
onHover: Function,
|
||||
},
|
||||
methods: {
|
||||
hover() {
|
||||
return this.onHover(this.user);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
24
client/components/UsernameFiltered.vue
Normal file
24
client/components/UsernameFiltered.vue
Normal file
@ -0,0 +1,24 @@
|
||||
<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>
|
@ -229,6 +229,12 @@ kbd {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.only-copy {
|
||||
font-size: 0;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
/* Icons */
|
||||
|
||||
#viewport .lt::before,
|
||||
@ -259,6 +265,7 @@ kbd {
|
||||
#chat .action .from::before,
|
||||
#chat .toggle-button::after,
|
||||
#chat .toggle-content .more-caret::before,
|
||||
#chat .scroll-down-arrow::after,
|
||||
#version-checker::before,
|
||||
.context-menu-item::before,
|
||||
#help .website-link::before,
|
||||
@ -325,8 +332,7 @@ kbd {
|
||||
|
||||
#help .website-link::before,
|
||||
#help .documentation-link::before,
|
||||
#help .report-issue-link::before,
|
||||
#chat .toggle-button {
|
||||
#help .report-issue-link::before {
|
||||
display: inline-block;
|
||||
margin-right: 5px;
|
||||
|
||||
@ -563,10 +569,6 @@ kbd {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#sidebar .network.collapsed .chan:not(.lobby) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#sidebar .chan {
|
||||
cursor: pointer;
|
||||
}
|
||||
@ -603,10 +605,6 @@ background on hover (unless active) */
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
#sidebar .networks:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#sidebar .network,
|
||||
#sidebar .network-placeholder {
|
||||
position: relative;
|
||||
@ -631,7 +629,7 @@ background on hover (unless active) */
|
||||
#sidebar .chan-placeholder {
|
||||
border: 1px dashed #99a2b4;
|
||||
border-radius: 6px;
|
||||
margin: -1px 10px;
|
||||
margin: -1px;
|
||||
}
|
||||
|
||||
#sidebar .network-placeholder {
|
||||
@ -779,12 +777,6 @@ background on hover (unless active) */
|
||||
transform: rotate(45deg) translateZ(0);
|
||||
}
|
||||
|
||||
#sidebar .network .lobby:nth-last-child(2) .collapse-network {
|
||||
/* Hide collapse button if there are no channels/queries */
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#sidebar .network .collapse-network {
|
||||
width: 40px;
|
||||
opacity: 0.4;
|
||||
@ -896,7 +888,7 @@ background on hover (unless active) */
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
#windows .window {
|
||||
.window {
|
||||
background: var(--window-bg-color);
|
||||
display: none;
|
||||
overflow-y: auto;
|
||||
@ -906,7 +898,7 @@ background on hover (unless active) */
|
||||
}
|
||||
|
||||
#chat .chan,
|
||||
#windows .window {
|
||||
.window {
|
||||
/* flexbox does not seem to scroll without doing this */
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
@ -1010,10 +1002,6 @@ background on hover (unless active) */
|
||||
}
|
||||
|
||||
#chat .condensed-summary {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#chat.condensed-status-messages .condensed-summary {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@ -1025,7 +1013,7 @@ background on hover (unless active) */
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#chat.condensed-status-messages .condensed.closed .msg {
|
||||
#chat .condensed.closed .msg {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@ -1055,6 +1043,7 @@ background on hover (unless active) */
|
||||
flex-direction: column;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#chat .userlist {
|
||||
@ -1070,7 +1059,6 @@ background on hover (unless active) */
|
||||
* Toggled via JavaScript
|
||||
*/
|
||||
#sidebar .join-form {
|
||||
display: none;
|
||||
padding: 0 18px 8px;
|
||||
}
|
||||
|
||||
@ -1089,11 +1077,11 @@ background on hover (unless active) */
|
||||
}
|
||||
|
||||
#chat .show-more {
|
||||
display: none;
|
||||
padding: 10px;
|
||||
padding-top: 15px;
|
||||
padding-bottom: 0;
|
||||
width: 100%;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#chat .show-more .btn {
|
||||
@ -1101,6 +1089,47 @@ background on hover (unless active) */
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.scroll-down {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
transition: all 0.2s ease-in-out;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.scroll-down-shown {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.scroll-down-arrow {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
line-height: 48px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
border: 1px solid #84ce88;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.scroll-down:hover .scroll-down-arrow {
|
||||
background: #84ce88;
|
||||
}
|
||||
|
||||
.scroll-down-arrow::after {
|
||||
content: "\f107"; /* https://fontawesome.com/icons/angle-down?style=solid */
|
||||
}
|
||||
|
||||
.userlist-open .channel .scroll-down {
|
||||
right: 196px;
|
||||
}
|
||||
|
||||
#chat .messages {
|
||||
padding: 10px 0;
|
||||
touch-action: pan-y;
|
||||
@ -1214,16 +1243,6 @@ background on hover (unless active) */
|
||||
color: var(--body-color-muted);
|
||||
}
|
||||
|
||||
#chat .special .time,
|
||||
#chat .special .from {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#chat .special .date-marker-container,
|
||||
#chat .special .unread-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#chat .special table th {
|
||||
word-break: normal;
|
||||
}
|
||||
@ -1279,7 +1298,7 @@ background on hover (unless active) */
|
||||
#chat.colored-nicks .user.color-31 { color: #eb0400; }
|
||||
#chat.colored-nicks .user.color-32 { color: #e60082; }
|
||||
|
||||
#chat .self .text {
|
||||
#chat .self .content {
|
||||
color: var(--body-color-muted);
|
||||
}
|
||||
|
||||
@ -1331,7 +1350,6 @@ background on hover (unless active) */
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
#chat.hide-status-messages .condensed,
|
||||
#chat.hide-motd .motd {
|
||||
display: none !important;
|
||||
}
|
||||
@ -1358,13 +1376,13 @@ background on hover (unless active) */
|
||||
}
|
||||
|
||||
#chat .action .from,
|
||||
#chat .action .text,
|
||||
#chat .action .content,
|
||||
#chat .action .user {
|
||||
color: #f39c12;
|
||||
}
|
||||
|
||||
#chat .notice .time,
|
||||
#chat .notice .text,
|
||||
#chat .notice .content,
|
||||
#chat .chan .notice .user {
|
||||
color: #0074d9 !important;
|
||||
}
|
||||
@ -1406,12 +1424,14 @@ background on hover (unless active) */
|
||||
#chat .toggle-content {
|
||||
background: #f6f6f6;
|
||||
border-radius: 5px;
|
||||
display: none;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
margin-top: 6px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
display: inline-flex !important;
|
||||
align-items: flex-start;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* This applies to images of preview-type-image and thumbnails of preview-type-link */
|
||||
@ -1502,11 +1522,6 @@ background on hover (unless active) */
|
||||
content: "\f0da"; /* https://fontawesome.com/icons/caret-right?style=solid */
|
||||
}
|
||||
|
||||
#chat .toggle-content.show {
|
||||
display: inline-flex !important;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
#chat audio {
|
||||
width: 600px;
|
||||
max-width: 100%;
|
||||
@ -1551,10 +1566,6 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
#chat .names-filtered {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#chat .names .user {
|
||||
display: block;
|
||||
line-height: 1.6;
|
||||
@ -1605,11 +1616,19 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
||||
content: "Search Results";
|
||||
}
|
||||
|
||||
#loading.active {
|
||||
#loading {
|
||||
font-size: 14px;
|
||||
z-index: 1;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#loading .window {
|
||||
height: initial;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
bottom: 5px;
|
||||
left: 5px;
|
||||
right: 5px;
|
||||
top: 5px;
|
||||
}
|
||||
|
||||
#loading p {
|
||||
@ -1646,6 +1665,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
#loading .logo-inverted,
|
||||
#windows .logo-inverted {
|
||||
display: none; /* In dark themes, inverted logo must be used instead */
|
||||
}
|
||||
@ -1945,21 +1965,16 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
#connection-error {
|
||||
font-size: 12px;
|
||||
line-height: 36px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 1px;
|
||||
#user-visible-error {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
font-weight: 600;
|
||||
padding: 10px;
|
||||
word-spacing: 3px;
|
||||
text-transform: uppercase;
|
||||
background: #e74c3c;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#connection-error.shown {
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@ -2008,6 +2023,11 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
#form #upload[disabled],
|
||||
#form #submit[disabled] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
#context-menu-container {
|
||||
display: none;
|
||||
position: absolute;
|
||||
@ -2369,7 +2389,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
left: -220px;
|
||||
z-index: 2;
|
||||
z-index: 10;
|
||||
transition: transform 160ms;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
@ -2384,7 +2404,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 160ms, visibility 160ms;
|
||||
z-index: 1;
|
||||
z-index: 9;
|
||||
}
|
||||
|
||||
#viewport.menu-open #sidebar-overlay {
|
||||
@ -2410,10 +2430,6 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
#sidebar .empty::before {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
#viewport .lt,
|
||||
#viewport .channel .rt {
|
||||
display: flex;
|
||||
@ -2486,6 +2502,12 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#chat .from::after {
|
||||
/* Add a space because mobile view changes to block display without paddings */
|
||||
content: " ";
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
#chat .channel .msg.highlight {
|
||||
padding-left: 5px;
|
||||
}
|
||||
@ -2635,14 +2657,14 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
||||
For example: user has quit ( ===> L O L <=== ) */
|
||||
|
||||
#windows .header .topic,
|
||||
#chat .message .text,
|
||||
#chat .motd .text,
|
||||
#chat .notice .text,
|
||||
#chat .message .content,
|
||||
#chat .motd .content,
|
||||
#chat .notice .content,
|
||||
#chat .ctcp-message,
|
||||
#chat .part-reason,
|
||||
#chat .quit-reason,
|
||||
#chat .new-topic,
|
||||
#chat .action .text,
|
||||
#chat .action .content,
|
||||
#chat table.channel-list .topic {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
@ -19,7 +19,7 @@
|
||||
<title>The Lounge</title>
|
||||
|
||||
<!-- Browser tab icon -->
|
||||
<link id="favicon" rel="icon" sizes="16x16 32x32 64x64" href="img/favicon-normal.ico" data-other="img/favicon-alerted.ico" data-toggled="false" type="image/x-icon">
|
||||
<link id="favicon" rel="icon" sizes="16x16 32x32 64x64" href="img/favicon-normal.ico" data-other="img/favicon-alerted.ico" type="image/x-icon">
|
||||
|
||||
<!-- Safari pinned tab icon -->
|
||||
<link rel="mask-icon" href="img/icon-black-transparent-bg.svg" color="#415363">
|
||||
@ -48,62 +48,21 @@
|
||||
|
||||
</head>
|
||||
<body class="signed-out<%- public ? " public" : "" %>" data-transports="<%- JSON.stringify(transports) %>">
|
||||
<div id="viewport" role="tablist">
|
||||
<aside id="sidebar">
|
||||
<div class="scrollable-area">
|
||||
<div class="logo-container">
|
||||
<img src="img/logo-<%- public ? 'horizontal-' : '' %>transparent-bg.svg" class="logo" alt="The Lounge">
|
||||
<img src="img/logo-<%- public ? 'horizontal-' : '' %>transparent-bg-inverted.svg" class="logo-inverted" alt="The Lounge">
|
||||
</div>
|
||||
<div class="networks"></div>
|
||||
<div class="empty">
|
||||
You are not connected to any networks yet.
|
||||
</div>
|
||||
<div id="loading">
|
||||
<div class="window">
|
||||
<div id="loading-status-container">
|
||||
<img src="img/logo-vertical-transparent-bg.svg" class="logo" alt="The Lounge" width="256" height="170">
|
||||
<img src="img/logo-vertical-transparent-bg-inverted.svg" class="logo-inverted" alt="The Lounge" width="256" height="170">
|
||||
<p id="loading-page-message"><a href="https://enable-javascript.com/" target="_blank" rel="noopener">Your JavaScript must be enabled.</a></p>
|
||||
</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"></button></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"></button></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"></button></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"></button></span>
|
||||
</footer>
|
||||
</aside>
|
||||
<div id="sidebar-overlay"></div>
|
||||
<article id="windows">
|
||||
<div id="loading" class="window active">
|
||||
<div id="loading-status-container">
|
||||
<img src="img/logo-vertical-transparent-bg.svg" class="logo" alt="The Lounge" width="256" height="170">
|
||||
<img src="img/logo-vertical-transparent-bg-inverted.svg" class="logo-inverted" alt="The Lounge" width="256" height="170">
|
||||
<p id="loading-page-message"><a href="https://enable-javascript.com/" target="_blank" rel="noopener">Your JavaScript must be enabled.</a></p>
|
||||
</div>
|
||||
<div id="loading-reload-container">
|
||||
<p id="loading-slow">This is taking longer than it should, there might be connectivity issues.</p>
|
||||
<button id="loading-reload" class="btn">Reload page</button>
|
||||
</div>
|
||||
<script async src="js/loading-error-handlers.js"></script>
|
||||
<div id="loading-reload-container">
|
||||
<p id="loading-slow">This is taking longer than it should, there might be connectivity issues.</p>
|
||||
<button id="loading-reload" class="btn">Reload page</button>
|
||||
</div>
|
||||
<div id="chat-container" class="window">
|
||||
<div id="chat"></div>
|
||||
<div id="connection-error"></div>
|
||||
<span id="upload-progressbar"></span>
|
||||
<form id="form" method="post" action="">
|
||||
<span id="nick"></span>
|
||||
<textarea id="input" class="mousetrap"></textarea>
|
||||
<span id="upload-tooltip" class="tooltipped tooltipped-w tooltipped-no-touch" aria-label="Upload file">
|
||||
<input id="upload-input" type="file" multiple>
|
||||
<button id="upload" type="button" aria-label="Upload file"></button>
|
||||
</span>
|
||||
<span id="submit-tooltip" class="tooltipped tooltipped-w tooltipped-no-touch" aria-label="Send message">
|
||||
<button id="submit" type="submit" aria-label="Send message"></button>
|
||||
</span>
|
||||
</form>
|
||||
</div>
|
||||
<div id="sign-in" class="window" role="tabpanel" aria-label="Sign-in"></div>
|
||||
<div id="connect" class="window" role="tabpanel" aria-label="Connect"></div>
|
||||
<div id="settings" class="window" role="tabpanel" aria-label="Settings"></div>
|
||||
<div id="help" class="window" role="tabpanel" aria-label="Help"></div>
|
||||
<div id="changelog" class="window" aria-label="Changelog"></div>
|
||||
</article>
|
||||
<script async src="js/loading-error-handlers.js"></script>
|
||||
</div>
|
||||
</div>
|
||||
<div id="viewport"></div>
|
||||
|
||||
<div id="context-menu-container"></div>
|
||||
<div id="image-viewer"></div>
|
||||
|
@ -5,10 +5,10 @@ const fuzzy = require("fuzzy");
|
||||
const Mousetrap = require("mousetrap");
|
||||
const {Textcomplete, Textarea} = require("textcomplete");
|
||||
const emojiMap = require("./libs/simplemap.json");
|
||||
const options = require("./options");
|
||||
const constants = require("./constants");
|
||||
const {vueApp} = require("./vue");
|
||||
|
||||
const input = $("#input");
|
||||
let input;
|
||||
let textcomplete;
|
||||
let enabled = false;
|
||||
|
||||
@ -16,22 +16,16 @@ module.exports = {
|
||||
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;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
$("#form").on("submit", () => {
|
||||
if (enabled) {
|
||||
textcomplete.hide();
|
||||
}
|
||||
});
|
||||
|
||||
const chat = $("#chat");
|
||||
const sidebar = $("#sidebar");
|
||||
const emojiSearchTerms = Object.keys(emojiMap);
|
||||
const emojiStrategy = {
|
||||
id: "emoji",
|
||||
@ -69,17 +63,17 @@ const nicksStrategy = {
|
||||
},
|
||||
replace([, original], position = 1) {
|
||||
// If no postfix specified, return autocompleted nick as-is
|
||||
if (!options.settings.nickPostfix) {
|
||||
if (!vueApp.settings.nickPostfix) {
|
||||
return original;
|
||||
}
|
||||
|
||||
// If there is whitespace in the input already, append space to nick
|
||||
if (position > 0 && /\s/.test(input.val())) {
|
||||
if (position > 0 && /\s/.test($("#input").val())) {
|
||||
return original + " ";
|
||||
}
|
||||
|
||||
// If nick is first in the input, append specified postfix
|
||||
return original + options.settings.nickPostfix;
|
||||
return original + vueApp.settings.nickPostfix;
|
||||
},
|
||||
index: 1,
|
||||
};
|
||||
@ -174,20 +168,25 @@ const backgroundColorStrategy = {
|
||||
index: 2,
|
||||
};
|
||||
|
||||
function enableAutocomplete() {
|
||||
function enableAutocomplete(inputRef) {
|
||||
enabled = true;
|
||||
let tabCount = 0;
|
||||
let lastMatch = "";
|
||||
let currentMatches = [];
|
||||
input = $(inputRef);
|
||||
|
||||
input.on("input.tabcomplete", (e) => {
|
||||
if (e.detail === "autocomplete") {
|
||||
return;
|
||||
}
|
||||
|
||||
input.on("input.tabcomplete", () => {
|
||||
tabCount = 0;
|
||||
currentMatches = [];
|
||||
lastMatch = "";
|
||||
});
|
||||
|
||||
Mousetrap(input.get(0)).bind("tab", (e) => {
|
||||
if (input.data("autocompleting")) {
|
||||
if (vueApp.isAutoCompleting) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -218,6 +217,11 @@ function enableAutocomplete() {
|
||||
|
||||
input.val(text.substr(0, position) + newMatch);
|
||||
|
||||
// Propagate change to Vue model
|
||||
input.get(0).dispatchEvent(new CustomEvent("input", {
|
||||
detail: "autocomplete",
|
||||
}));
|
||||
|
||||
lastMatch = newMatch;
|
||||
tabCount++;
|
||||
}, "keydown");
|
||||
@ -248,11 +252,15 @@ function enableAutocomplete() {
|
||||
});
|
||||
|
||||
textcomplete.on("show", () => {
|
||||
input.data("autocompleting", true);
|
||||
vueApp.isAutoCompleting = true;
|
||||
});
|
||||
|
||||
textcomplete.on("hidden", () => {
|
||||
input.data("autocompleting", false);
|
||||
vueApp.isAutoCompleting = false;
|
||||
});
|
||||
|
||||
$("#form").on("submit.tabcomplete", () => {
|
||||
textcomplete.hide();
|
||||
});
|
||||
}
|
||||
|
||||
@ -269,19 +277,17 @@ function fuzzyGrep(term, array) {
|
||||
}
|
||||
|
||||
function rawNicks() {
|
||||
const chan = chat.find(".active");
|
||||
const users = chan.find(".userlist");
|
||||
if (vueApp.activeChannel.channel.users.length > 0) {
|
||||
const users = vueApp.activeChannel.channel.users.slice();
|
||||
|
||||
// If this channel has a list of nicks, just return it
|
||||
if (users.length > 0) {
|
||||
return users.data("nicks");
|
||||
return users.sort((a, b) => b.lastMessage - a.lastMessage).map((u) => u.nick);
|
||||
}
|
||||
|
||||
const me = $("#nick").text();
|
||||
const otherUser = chan.attr("aria-label");
|
||||
const me = vueApp.activeChannel.network.nick;
|
||||
const otherUser = vueApp.activeChannel.channel.name;
|
||||
|
||||
// If this is a query, add their name to autocomplete
|
||||
if (me !== otherUser && chan.data("type") === "query") {
|
||||
if (me !== otherUser && vueApp.activeChannel.channel.type === "query") {
|
||||
return [otherUser, me];
|
||||
}
|
||||
|
||||
@ -312,16 +318,11 @@ function completeCommands(word) {
|
||||
function completeChans(word) {
|
||||
const words = [];
|
||||
|
||||
sidebar.find(".chan.active")
|
||||
.parent()
|
||||
.find(".chan")
|
||||
.each(function() {
|
||||
const self = $(this);
|
||||
|
||||
if (self.hasClass("channel")) {
|
||||
words.push(self.attr("aria-label"));
|
||||
}
|
||||
});
|
||||
for (const channel of vueApp.activeChannel.network.channels) {
|
||||
if (channel.type === "channel") {
|
||||
words.push(channel.name);
|
||||
}
|
||||
}
|
||||
|
||||
return fuzzyGrep(word, words);
|
||||
}
|
||||
|
@ -1,9 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
const $ = require("jquery");
|
||||
const chat = document.getElementById("chat");
|
||||
|
||||
function copyMessages() {
|
||||
module.exports = function(chat) {
|
||||
const selection = window.getSelection();
|
||||
|
||||
// If selection does not span multiple elements, do nothing
|
||||
@ -15,17 +12,6 @@ function copyMessages() {
|
||||
const documentFragment = range.cloneContents();
|
||||
const div = document.createElement("div");
|
||||
|
||||
$(documentFragment)
|
||||
.find(".from .user")
|
||||
.each((_, el) => {
|
||||
el = $(el);
|
||||
el.text(`<${el.text()}>`);
|
||||
});
|
||||
|
||||
$(documentFragment)
|
||||
.find(".content > .user")
|
||||
.prepend("* ");
|
||||
|
||||
div.id = "js-copy-hack";
|
||||
div.appendChild(documentFragment);
|
||||
chat.appendChild(div);
|
||||
@ -37,6 +23,4 @@ function copyMessages() {
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
$(chat).on("copy", ".messages", copyMessages);
|
||||
};
|
||||
|
8
client/js/commands/collapse.js
Normal file
8
client/js/commands/collapse.js
Normal file
@ -0,0 +1,8 @@
|
||||
"use strict";
|
||||
|
||||
const $ = require("jquery");
|
||||
|
||||
exports.input = function() {
|
||||
$(".chan.active .toggle-button.toggle-preview.opened").click();
|
||||
return true;
|
||||
};
|
8
client/js/commands/expand.js
Normal file
8
client/js/commands/expand.js
Normal file
@ -0,0 +1,8 @@
|
||||
"use strict";
|
||||
|
||||
const $ = require("jquery");
|
||||
|
||||
exports.input = function() {
|
||||
$(".chan.active .toggle-button.toggle-preview:not(.opened)").click();
|
||||
return true;
|
||||
};
|
21
client/js/commands/index.js
Normal file
21
client/js/commands/index.js
Normal file
@ -0,0 +1,21 @@
|
||||
"use strict";
|
||||
|
||||
// Taken from views/index.js
|
||||
|
||||
// This creates a version of `require()` in the context of the current
|
||||
// directory, so we iterate over its content, which is a map statically built by
|
||||
// Webpack.
|
||||
// Second argument says it's recursive, third makes sure we only load javascript.
|
||||
const commands = require.context("./", true, /\.js$/);
|
||||
|
||||
module.exports = commands.keys().reduce((acc, path) => {
|
||||
const command = path.substring(2, path.length - 3);
|
||||
|
||||
if (command === "index") {
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc[command] = commands(path);
|
||||
|
||||
return acc;
|
||||
}, {});
|
29
client/js/commands/join.js
Normal file
29
client/js/commands/join.js
Normal file
@ -0,0 +1,29 @@
|
||||
"use strict";
|
||||
|
||||
const $ = require("jquery");
|
||||
|
||||
exports.input = function(args) {
|
||||
const utils = require("../utils");
|
||||
const socket = require("../socket");
|
||||
const {vueApp} = require("../vue");
|
||||
|
||||
if (args.length > 0) {
|
||||
const channel = args[0];
|
||||
|
||||
if (channel.length > 0) {
|
||||
const chan = utils.findCurrentNetworkChan(channel);
|
||||
|
||||
if (chan) {
|
||||
$(`#sidebar .chan[data-id="${chan.id}"]`).trigger("click");
|
||||
}
|
||||
}
|
||||
} else if (vueApp.activeChannel.channel.type === "channel") {
|
||||
// If `/join` command is used without any arguments, re-join current channel
|
||||
socket.emit("input", {
|
||||
target: vueApp.activeChannel.channel.id,
|
||||
text: `/join ${vueApp.activeChannel.channel.name}`,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
@ -1,72 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const constants = require("./constants");
|
||||
const templates = require("../views");
|
||||
|
||||
module.exports = {
|
||||
updateText,
|
||||
getStoredTypes,
|
||||
};
|
||||
|
||||
function getStoredTypes(condensed) {
|
||||
const obj = {};
|
||||
|
||||
constants.condensedTypes.forEach((type) => {
|
||||
obj[type] = condensed.data(type) || 0;
|
||||
});
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
function updateText(condensed, addedTypes) {
|
||||
const obj = getStoredTypes(condensed);
|
||||
|
||||
Object.keys(addedTypes).map((type) => {
|
||||
obj[type] += addedTypes[type];
|
||||
condensed.data(type, obj[type]);
|
||||
});
|
||||
|
||||
// Count quits as parts in condensed messages to reduce information density
|
||||
obj.part += obj.quit;
|
||||
|
||||
const strings = [];
|
||||
constants.condensedTypes.forEach((type) => {
|
||||
if (obj[type]) {
|
||||
switch (type) {
|
||||
case "away":
|
||||
strings.push(obj[type] + (obj[type] > 1 ? " users have gone away" : " user has gone away"));
|
||||
break;
|
||||
case "back":
|
||||
strings.push(obj[type] + (obj[type] > 1 ? " users have come back" : " user has come back"));
|
||||
break;
|
||||
case "chghost":
|
||||
strings.push(obj[type] + (obj[type] > 1 ? " users have changed hostname" : " user has changed hostname"));
|
||||
break;
|
||||
case "join":
|
||||
strings.push(obj[type] + (obj[type] > 1 ? " users have joined" : " user has joined"));
|
||||
break;
|
||||
case "part":
|
||||
strings.push(obj[type] + (obj[type] > 1 ? " users have left" : " user has left"));
|
||||
break;
|
||||
case "nick":
|
||||
strings.push(obj[type] + (obj[type] > 1 ? " users have changed nick" : " user has changed nick"));
|
||||
break;
|
||||
case "kick":
|
||||
strings.push(obj[type] + (obj[type] > 1 ? " users were kicked" : " user was kicked"));
|
||||
break;
|
||||
case "mode":
|
||||
strings.push(obj[type] + (obj[type] > 1 ? " modes were set" : " mode was set"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let text = strings.pop();
|
||||
|
||||
if (strings.length) {
|
||||
text = strings.join(", ") + ", and " + text;
|
||||
}
|
||||
|
||||
condensed.find(".condensed-summary .content")
|
||||
.html(text + templates.msg_condensed_toggle());
|
||||
}
|
@ -1,11 +1,12 @@
|
||||
"use strict";
|
||||
|
||||
const $ = require("jquery");
|
||||
const socket = require("./socket");
|
||||
const utils = require("./utils");
|
||||
const JoinChannel = require("./join-channel");
|
||||
const ContextMenu = require("./contextMenu");
|
||||
const contextMenuActions = [];
|
||||
const contextMenuItems = [];
|
||||
const {findChannel} = require("./vue");
|
||||
|
||||
module.exports = {
|
||||
addContextMenuItem,
|
||||
@ -52,12 +53,12 @@ function addWhoisItem() {
|
||||
function whois(itemData) {
|
||||
const chan = utils.findCurrentNetworkChan(itemData);
|
||||
|
||||
if (chan.length) {
|
||||
chan.click();
|
||||
if (chan) {
|
||||
$(`#sidebar .chan[data-id="${chan.id}"]`).trigger("click");
|
||||
}
|
||||
|
||||
socket.emit("input", {
|
||||
target: $("#chat").data("id"),
|
||||
target: Number($("#chat").attr("data-id")),
|
||||
text: "/whois " + itemData,
|
||||
});
|
||||
}
|
||||
@ -87,12 +88,12 @@ function addQueryItem() {
|
||||
function query(itemData) {
|
||||
const chan = utils.findCurrentNetworkChan(itemData);
|
||||
|
||||
if (chan.length) {
|
||||
chan.click();
|
||||
if (chan) {
|
||||
$(`#sidebar .chan[data-id="${chan.id}"]`).trigger("click");
|
||||
}
|
||||
|
||||
socket.emit("input", {
|
||||
target: $("#chat").data("id"),
|
||||
target: Number($("#chat").attr("data-id")),
|
||||
text: "/query " + itemData,
|
||||
});
|
||||
}
|
||||
@ -138,7 +139,7 @@ function addConnectItem() {
|
||||
check: (target) => target.hasClass("lobby") && target.parent().hasClass("not-connected"),
|
||||
className: "connect",
|
||||
displayName: "Connect",
|
||||
data: (target) => target.data("id"),
|
||||
data: (target) => target.attr("data-id"),
|
||||
callback: connect,
|
||||
});
|
||||
}
|
||||
@ -155,7 +156,7 @@ function addDisconnectItem() {
|
||||
check: (target) => target.hasClass("lobby") && !target.parent().hasClass("not-connected"),
|
||||
className: "disconnect",
|
||||
displayName: "Disconnect",
|
||||
data: (target) => target.data("id"),
|
||||
data: (target) => target.attr("data-id"),
|
||||
callback: disconnect,
|
||||
});
|
||||
}
|
||||
@ -163,13 +164,13 @@ function addDisconnectItem() {
|
||||
function addKickItem() {
|
||||
function kick(itemData) {
|
||||
socket.emit("input", {
|
||||
target: $("#chat").data("id"),
|
||||
target: Number($("#chat").attr("data-id")),
|
||||
text: "/kick " + itemData,
|
||||
});
|
||||
}
|
||||
|
||||
addContextMenuItem({
|
||||
check: (target) => utils.hasRoleInChannel(target.closest(".chan"), ["op"]) && target.closest(".chan").data("type") === "channel",
|
||||
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"),
|
||||
@ -180,7 +181,7 @@ function addKickItem() {
|
||||
function addOpItem() {
|
||||
function op(itemData) {
|
||||
socket.emit("input", {
|
||||
target: $("#chat").data("id"),
|
||||
target: Number($("#chat").attr("data-id")),
|
||||
text: "/op " + itemData,
|
||||
});
|
||||
}
|
||||
@ -199,7 +200,7 @@ function addOpItem() {
|
||||
function addDeopItem() {
|
||||
function deop(itemData) {
|
||||
socket.emit("input", {
|
||||
target: $("#chat").data("id"),
|
||||
target: Number($("#chat").attr("data-id")),
|
||||
text: "/deop " + itemData,
|
||||
});
|
||||
}
|
||||
@ -218,7 +219,7 @@ function addDeopItem() {
|
||||
function addVoiceItem() {
|
||||
function voice(itemData) {
|
||||
socket.emit("input", {
|
||||
target: $("#chat").data("id"),
|
||||
target: Number($("#chat").attr("data-id")),
|
||||
text: "/voice " + itemData,
|
||||
});
|
||||
}
|
||||
@ -237,7 +238,7 @@ function addVoiceItem() {
|
||||
function addDevoiceItem() {
|
||||
function devoice(itemData) {
|
||||
socket.emit("input", {
|
||||
target: $("#chat").data("id"),
|
||||
target: Number($("#chat").attr("data-id")),
|
||||
text: "/devoice " + itemData,
|
||||
});
|
||||
}
|
||||
@ -271,7 +272,7 @@ function addFocusItem() {
|
||||
addContextMenuItem({
|
||||
check: (target) => target.hasClass("chan"),
|
||||
className: getClass,
|
||||
displayName: (target) => target.attr("aria-label"),
|
||||
displayName: (target) => target.attr("data-name") || target.attr("aria-label"),
|
||||
data: (target) => target.attr("data-target"),
|
||||
callback: focusChan,
|
||||
});
|
||||
@ -291,7 +292,7 @@ function addEditNetworkItem() {
|
||||
check: (target) => target.hasClass("lobby"),
|
||||
className: "edit",
|
||||
displayName: "Edit this network…",
|
||||
data: (target) => target.closest(".network").data("uuid"),
|
||||
data: (target) => target.closest(".network").attr("data-uuid"),
|
||||
callback: edit,
|
||||
});
|
||||
}
|
||||
@ -308,7 +309,7 @@ function addChannelListItem() {
|
||||
check: (target) => target.hasClass("lobby"),
|
||||
className: "list",
|
||||
displayName: "List all channels",
|
||||
data: (target) => target.data("id"),
|
||||
data: (target) => target.attr("data-id"),
|
||||
callback: list,
|
||||
});
|
||||
}
|
||||
@ -325,22 +326,21 @@ function addBanListItem() {
|
||||
check: (target) => target.hasClass("channel"),
|
||||
className: "list",
|
||||
displayName: "List banned users",
|
||||
data: (target) => target.data("id"),
|
||||
data: (target) => target.attr("data-id"),
|
||||
callback: banlist,
|
||||
});
|
||||
}
|
||||
|
||||
function addJoinItem() {
|
||||
function openJoinForm(itemData) {
|
||||
const network = $(`#join-channel-${itemData}`).closest(".network");
|
||||
JoinChannel.openForm(network);
|
||||
findChannel(Number(itemData)).network.isJoinChannelShown = true;
|
||||
}
|
||||
|
||||
addContextMenuItem({
|
||||
check: (target) => target.hasClass("lobby"),
|
||||
className: "join",
|
||||
displayName: "Join a channel…",
|
||||
data: (target) => target.data("id"),
|
||||
data: (target) => target.attr("data-id"),
|
||||
callback: openJoinForm,
|
||||
});
|
||||
}
|
||||
@ -357,7 +357,7 @@ function addIgnoreListItem() {
|
||||
check: (target) => target.hasClass("lobby"),
|
||||
className: "list",
|
||||
displayName: "List ignored users",
|
||||
data: (target) => target.data("id"),
|
||||
data: (target) => target.attr("data-id"),
|
||||
callback: ignorelist,
|
||||
});
|
||||
}
|
||||
|
@ -1,96 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const $ = require("jquery");
|
||||
const Mousetrap = require("mousetrap");
|
||||
|
||||
const socket = require("./socket");
|
||||
const utils = require("./utils");
|
||||
|
||||
const sidebar = $("#sidebar");
|
||||
|
||||
module.exports = {
|
||||
handleKeybinds,
|
||||
openForm,
|
||||
};
|
||||
|
||||
function toggleButton(network) {
|
||||
// Transform the + button to a ×
|
||||
network.find("button.add-channel").toggleClass("opened");
|
||||
|
||||
// Toggle content of tooltip
|
||||
const tooltip = network.find(".add-channel-tooltip");
|
||||
const altLabel = tooltip.data("alt-label");
|
||||
tooltip.data("alt-label", tooltip.attr("aria-label"));
|
||||
tooltip.attr("aria-label", altLabel);
|
||||
}
|
||||
|
||||
function closeForm(network) {
|
||||
const form = network.find(".join-form");
|
||||
|
||||
if (form.is(":visible")) {
|
||||
form.find("input[name='channel']").val("");
|
||||
form.find("input[name='key']").val("");
|
||||
form.hide();
|
||||
toggleButton(network);
|
||||
}
|
||||
}
|
||||
|
||||
function openForm(network) {
|
||||
const form = network.find(".join-form");
|
||||
|
||||
if (form.is(":hidden")) {
|
||||
form.show();
|
||||
toggleButton(network);
|
||||
}
|
||||
|
||||
// Focus the "Channel" field even if the form was already open
|
||||
form.find(".input[name='channel']").trigger("focus");
|
||||
}
|
||||
|
||||
sidebar.on("click", ".add-channel", function(e) {
|
||||
const id = $(e.target).closest(".lobby").data("id");
|
||||
const joinForm = $(`#join-channel-${id}`);
|
||||
const network = joinForm.closest(".network");
|
||||
|
||||
if (joinForm.is(":visible")) {
|
||||
closeForm(network);
|
||||
} else {
|
||||
openForm(network);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
function handleKeybinds(networks) {
|
||||
for (const network of networks) {
|
||||
const form = $(`.network[data-uuid="${network.uuid}"] .join-form`);
|
||||
|
||||
form.find("input, button").each(function() {
|
||||
Mousetrap(this).bind("esc", () => {
|
||||
closeForm(form.closest(".network"));
|
||||
|
||||
return false;
|
||||
});
|
||||
});
|
||||
|
||||
form.on("submit", () => {
|
||||
const networkElement = form.closest(".network");
|
||||
const channel = form.find("input[name='channel']").val();
|
||||
const key = form.find("input[name='key']").val();
|
||||
const existingChannel = utils.findCurrentNetworkChan(channel);
|
||||
|
||||
if (existingChannel.length) {
|
||||
existingChannel.trigger("click");
|
||||
} else {
|
||||
socket.emit("input", {
|
||||
text: `/join ${channel} ${key}`,
|
||||
target: networkElement.find(".lobby").data("id"),
|
||||
});
|
||||
}
|
||||
|
||||
closeForm(networkElement);
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
@ -2,44 +2,13 @@
|
||||
|
||||
const $ = require("jquery");
|
||||
const Mousetrap = require("mousetrap");
|
||||
const wrapCursor = require("undate").wrapCursor;
|
||||
const utils = require("./utils");
|
||||
const form = $("#form");
|
||||
const input = $("#input");
|
||||
const sidebar = $("#sidebar");
|
||||
const windows = $("#windows");
|
||||
|
||||
Mousetrap.bind([
|
||||
"pageup",
|
||||
"pagedown",
|
||||
], function(e, key) {
|
||||
let container = windows.find(".window.active");
|
||||
|
||||
// Chat windows scroll message container
|
||||
if (container.prop("id") === "chat-container") {
|
||||
container = container.find(".chan.active .chat");
|
||||
}
|
||||
|
||||
container.finish();
|
||||
|
||||
const offset = container.get(0).clientHeight * 0.9;
|
||||
let scrollTop = container.scrollTop();
|
||||
|
||||
if (key === "pageup") {
|
||||
scrollTop = Math.floor(scrollTop - offset);
|
||||
} else {
|
||||
scrollTop = Math.ceil(scrollTop + offset);
|
||||
}
|
||||
|
||||
container.animate({scrollTop}, 200);
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
Mousetrap.bind([
|
||||
"alt+up",
|
||||
"alt+down",
|
||||
], function(e, keys) {
|
||||
const sidebar = $("#sidebar");
|
||||
const channels = sidebar.find(".chan").not(".network.collapsed :not(.lobby)");
|
||||
const index = channels.index(channels.filter(".active"));
|
||||
const direction = keys.split("+").pop();
|
||||
@ -65,6 +34,7 @@ Mousetrap.bind([
|
||||
"alt+shift+up",
|
||||
"alt+shift+down",
|
||||
], function(e, keys) {
|
||||
const sidebar = $("#sidebar");
|
||||
const lobbies = sidebar.find(".lobby");
|
||||
const direction = keys.split("+").pop();
|
||||
let index = lobbies.index(lobbies.filter(".active"));
|
||||
@ -96,113 +66,6 @@ Mousetrap.bind([
|
||||
return false;
|
||||
});
|
||||
|
||||
const inputTrap = Mousetrap(input.get(0));
|
||||
|
||||
function enableHistory() {
|
||||
const history = [""];
|
||||
let position = 0;
|
||||
|
||||
input.on("input", () => {
|
||||
position = 0;
|
||||
});
|
||||
|
||||
inputTrap.bind("enter", function() {
|
||||
position = 0;
|
||||
|
||||
if (input.data("autocompleting")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const text = input.val();
|
||||
|
||||
if (text.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Submit the form when pressing enter instead of inserting a new line
|
||||
form.trigger("submit");
|
||||
|
||||
// Store new message in history if last message isn't already equal
|
||||
if (history[1] !== text) {
|
||||
history.splice(1, 0, text);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
inputTrap.bind(["up", "down"], function(e, key) {
|
||||
if (e.target.selectionStart !== e.target.selectionEnd || input.data("autocompleting")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (position === 0) {
|
||||
history[position] = input.val();
|
||||
}
|
||||
|
||||
if (key === "up") {
|
||||
if (position < history.length - 1) {
|
||||
position++;
|
||||
}
|
||||
} else if (position > 0) {
|
||||
position--;
|
||||
}
|
||||
|
||||
input.val(history[position]);
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
enableHistory();
|
||||
|
||||
const colorsHotkeys = {
|
||||
k: "\x03",
|
||||
b: "\x02",
|
||||
u: "\x1F",
|
||||
i: "\x1D",
|
||||
o: "\x0F",
|
||||
s: "\x1e",
|
||||
m: "\x11",
|
||||
};
|
||||
|
||||
for (const hotkey in colorsHotkeys) {
|
||||
inputTrap.bind("mod+" + hotkey, function(e) {
|
||||
// Key is lowercased because keybinds also get processed if caps lock is on
|
||||
const modifier = colorsHotkeys[e.key.toLowerCase()];
|
||||
|
||||
wrapCursor(
|
||||
e.target,
|
||||
modifier,
|
||||
e.target.selectionStart === e.target.selectionEnd ? "" : modifier
|
||||
);
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// Autocomplete bracket and quote characters like in a modern IDE
|
||||
// For example, select `text`, press `[` key, and it becomes `[text]`
|
||||
const bracketWraps = {
|
||||
'"': '"',
|
||||
"'": "'",
|
||||
"(": ")",
|
||||
"<": ">",
|
||||
"[": "]",
|
||||
"{": "}",
|
||||
"*": "*",
|
||||
"`": "`",
|
||||
"~": "~",
|
||||
"_": "_",
|
||||
};
|
||||
|
||||
inputTrap.bind(Object.keys(bracketWraps), function(e) {
|
||||
if (e.target.selectionStart !== e.target.selectionEnd) {
|
||||
wrapCursor(e.target, e.key, bracketWraps[e.key]);
|
||||
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// Ignored keys which should not automatically focus the input bar
|
||||
const ignoredKeys = {
|
||||
8: true, // Backspace
|
||||
@ -214,8 +77,6 @@ const ignoredKeys = {
|
||||
19: true, // Pause
|
||||
20: true, // CapsLock
|
||||
27: true, // Escape
|
||||
33: true, // PageUp
|
||||
34: true, // PageDown
|
||||
35: true, // End
|
||||
36: true, // Home
|
||||
37: true, // ArrowLeft
|
||||
@ -253,6 +114,12 @@ $(document).on("keydown", (e) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Redirect pagedown/pageup keys to messages container so it scrolls
|
||||
if (e.which === 33 || e.which === 34) {
|
||||
$("#windows .window.active .chan.active .chat").trigger("focus");
|
||||
return;
|
||||
}
|
||||
|
||||
const tagName = e.target.tagName;
|
||||
|
||||
// Ignore if we're already typing into <input> or <textarea>
|
||||
@ -260,6 +127,8 @@ $(document).on("keydown", (e) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const input = $("#input");
|
||||
|
||||
// On enter, focus the input but do not propagate the event
|
||||
// This way, a new line is not inserted
|
||||
if (e.which === 13) {
|
||||
|
@ -1,12 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
let diff;
|
||||
|
||||
module.exports = function(a, opt) {
|
||||
if (a !== diff) {
|
||||
diff = a;
|
||||
return opt.fn(this);
|
||||
}
|
||||
|
||||
return opt.inverse(this);
|
||||
};
|
@ -1,13 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const moment = require("moment");
|
||||
|
||||
module.exports = function(time) {
|
||||
// See http://momentjs.com/docs/#/displaying/calendar-time/
|
||||
return moment(time).calendar(null, {
|
||||
sameDay: "[Today]",
|
||||
lastDay: "[Yesterday]",
|
||||
lastWeek: "D MMMM YYYY",
|
||||
sameElse: "D MMMM YYYY",
|
||||
});
|
||||
};
|
@ -1,7 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const moment = require("moment");
|
||||
|
||||
module.exports = function(time) {
|
||||
return moment(time).format("D MMMM YYYY");
|
||||
};
|
@ -1,15 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const modes = {
|
||||
"~": "owner",
|
||||
"&": "admin",
|
||||
"!": "admin",
|
||||
"@": "op",
|
||||
"%": "half-op",
|
||||
"+": "voice",
|
||||
"": "normal",
|
||||
};
|
||||
|
||||
module.exports = function(mode) {
|
||||
return modes[mode];
|
||||
};
|
@ -1,18 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = function(a, b, opt) {
|
||||
if (arguments.length !== 3) {
|
||||
throw new Error("Handlebars helper `notEqual` expects 3 arguments");
|
||||
}
|
||||
|
||||
a = a.toString();
|
||||
b = b.toString();
|
||||
|
||||
if (a !== b) {
|
||||
return opt.fn(this);
|
||||
}
|
||||
|
||||
if (opt.inverse(this) !== "") {
|
||||
throw new Error("Handlebars helper `notEqual` does not take an `else` block");
|
||||
}
|
||||
};
|
@ -1,6 +1,5 @@
|
||||
"use strict";
|
||||
|
||||
const Handlebars = require("handlebars/runtime");
|
||||
const parseStyle = require("./ircmessageparser/parseStyle");
|
||||
const findChannels = require("./ircmessageparser/findChannels");
|
||||
const findLinks = require("./ircmessageparser/findLinks");
|
||||
@ -9,9 +8,10 @@ const findNames = require("./ircmessageparser/findNames");
|
||||
const merge = require("./ircmessageparser/merge");
|
||||
const colorClass = require("./colorClass");
|
||||
const emojiMap = require("../fullnamemap.json");
|
||||
const LinkPreviewToggle = require("../../../components/LinkPreviewToggle.vue").default;
|
||||
|
||||
// Create an HTML `span` with styling information for a given fragment
|
||||
function createFragment(fragment) {
|
||||
function createFragment(fragment, createElement) {
|
||||
const classes = [];
|
||||
|
||||
if (fragment.bold) {
|
||||
@ -42,34 +42,31 @@ function createFragment(fragment) {
|
||||
classes.push("irc-monospace");
|
||||
}
|
||||
|
||||
let attributes = classes.length ? ` class="${classes.join(" ")}"` : "";
|
||||
const escapedText = Handlebars.Utils.escapeExpression(fragment.text);
|
||||
const data = {};
|
||||
let hasData = false;
|
||||
|
||||
if (classes.length > 0) {
|
||||
hasData = true;
|
||||
data.class = classes;
|
||||
}
|
||||
|
||||
if (fragment.hexColor) {
|
||||
attributes += ` style="color:#${fragment.hexColor}`;
|
||||
hasData = true;
|
||||
data.style = {
|
||||
color: `#${fragment.hexColor}`,
|
||||
};
|
||||
|
||||
if (fragment.hexBgColor) {
|
||||
attributes += `;background-color:#${fragment.hexBgColor}`;
|
||||
data.style["background-color"] = `#${fragment.hexBgColor}`;
|
||||
}
|
||||
|
||||
attributes += '"';
|
||||
}
|
||||
|
||||
if (attributes.length) {
|
||||
return `<span${attributes}>${escapedText}</span>`;
|
||||
}
|
||||
|
||||
return escapedText;
|
||||
return hasData ? createElement("span", data, fragment.text) : fragment.text;
|
||||
}
|
||||
|
||||
// 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.
|
||||
module.exports = function parse(text, users) {
|
||||
// if it's not the users we're expecting, but rather is passed from Handlebars (occurs when users passed to template is null or undefined)
|
||||
if (users && users.hash) {
|
||||
users = [];
|
||||
}
|
||||
|
||||
module.exports = function parse(createElement, text, message = undefined, network = undefined) {
|
||||
// Extract the styling information and get the plain text version from it
|
||||
const styleFragments = parseStyle(text);
|
||||
const cleanText = styleFragments.map((fragment) => fragment.text).join("");
|
||||
@ -77,12 +74,12 @@ module.exports = function parse(text, users) {
|
||||
// On the plain text, find channels and URLs, returned as "parts". Parts are
|
||||
// arrays of objects containing start and end markers, as well as metadata
|
||||
// depending on what was found (channel or link).
|
||||
const channelPrefixes = ["#", "&"]; // TODO Channel prefixes should be RPL_ISUPPORT.CHANTYPES
|
||||
const userModes = ["!", "@", "%", "+"]; // TODO User modes should be RPL_ISUPPORT.PREFIX
|
||||
const channelPrefixes = network ? network.serverOptions.CHANTYPES : ["#", "&"];
|
||||
const userModes = network ? network.serverOptions.PREFIX : ["!", "@", "%", "+"];
|
||||
const channelParts = findChannels(cleanText, channelPrefixes, userModes);
|
||||
const linkParts = findLinks(cleanText);
|
||||
const emojiParts = findEmoji(cleanText);
|
||||
const nameParts = findNames(cleanText, (users || []));
|
||||
const nameParts = findNames(cleanText, message ? (message.users || []) : []);
|
||||
|
||||
const parts = channelParts
|
||||
.concat(linkParts)
|
||||
@ -92,27 +89,66 @@ module.exports = function parse(text, users) {
|
||||
// Merge the styling information with the channels / URLs / nicks / text objects and
|
||||
// generate HTML strings with the resulting fragments
|
||||
return merge(parts, styleFragments, cleanText).map((textPart) => {
|
||||
// Create HTML strings with styling information
|
||||
const fragments = textPart.fragments.map(createFragment).join("");
|
||||
const fragments = textPart.fragments.map((fragment) => createFragment(fragment, createElement));
|
||||
|
||||
// Wrap these potentially styled fragments with links and channel buttons
|
||||
if (textPart.link) {
|
||||
const escapedLink = Handlebars.Utils.escapeExpression(textPart.link);
|
||||
return `<a href="${escapedLink}" target="_blank" rel="noopener">${fragments}</a>`;
|
||||
} else if (textPart.channel) {
|
||||
const escapedChannel = Handlebars.Utils.escapeExpression(textPart.channel);
|
||||
return `<span class="inline-channel" role="button" tabindex="0" data-chan="${escapedChannel}">${fragments}</span>`;
|
||||
} else if (textPart.emoji) {
|
||||
if (!emojiMap[textPart.emoji]) {
|
||||
return `<span class="emoji" role="img">${fragments}</span>`;
|
||||
const preview = message && message.previews.find((p) => p.link === textPart.link);
|
||||
const link = createElement("a", {
|
||||
attrs: {
|
||||
href: textPart.link,
|
||||
target: "_blank",
|
||||
rel: "noopener",
|
||||
},
|
||||
}, fragments);
|
||||
|
||||
if (!preview) {
|
||||
return link;
|
||||
}
|
||||
|
||||
return `<span class="emoji" role="img" aria-label="Emoji: ${emojiMap[textPart.emoji]}" title="${emojiMap[textPart.emoji]}">${fragments}</span>`;
|
||||
return [link, createElement(LinkPreviewToggle, {
|
||||
class: ["toggle-button", "toggle-preview"],
|
||||
props: {
|
||||
link: preview,
|
||||
},
|
||||
}, fragments)];
|
||||
} else if (textPart.channel) {
|
||||
return createElement("span", {
|
||||
class: [
|
||||
"inline-channel",
|
||||
],
|
||||
attrs: {
|
||||
"role": "button",
|
||||
"tabindex": 0,
|
||||
"data-chan": textPart.channel,
|
||||
},
|
||||
}, fragments);
|
||||
} else if (textPart.emoji) {
|
||||
const title = emojiMap[textPart.emoji] ? `Emoji: ${emojiMap[textPart.emoji]}` : null;
|
||||
|
||||
return createElement("span", {
|
||||
class: [
|
||||
"emoji",
|
||||
],
|
||||
attrs: {
|
||||
"role": "img",
|
||||
"aria-label": title,
|
||||
"title": title,
|
||||
},
|
||||
}, fragments);
|
||||
} else if (textPart.nick) {
|
||||
const nick = Handlebars.Utils.escapeExpression(textPart.nick);
|
||||
return `<span role="button" class="user ${colorClass(textPart.nick)}" data-name="${nick}">${fragments}</span>`;
|
||||
return createElement("span", {
|
||||
class: [
|
||||
"user",
|
||||
colorClass(textPart.nick),
|
||||
],
|
||||
attrs: {
|
||||
"role": "button",
|
||||
"data-name": textPart.nick,
|
||||
},
|
||||
}, fragments);
|
||||
}
|
||||
|
||||
return fragments;
|
||||
}).join("");
|
||||
});
|
||||
};
|
||||
|
@ -1,7 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const escape = require("css.escape");
|
||||
|
||||
module.exports = function(orig) {
|
||||
return escape(orig.toLowerCase());
|
||||
};
|
@ -1,5 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = function tojson(context) {
|
||||
return JSON.stringify(context);
|
||||
};
|
@ -1,10 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const moment = require("moment");
|
||||
const constants = require("../../constants");
|
||||
|
||||
module.exports = function(time) {
|
||||
const options = require("../../options");
|
||||
const format = options.settings.showSeconds ? constants.timeFormats.msgWithSeconds : constants.timeFormats.msgDefault;
|
||||
return moment(time).format(format);
|
||||
};
|
55
client/js/libs/jquery/stickyscroll.js
vendored
55
client/js/libs/jquery/stickyscroll.js
vendored
@ -1,55 +0,0 @@
|
||||
import jQuery from "jquery";
|
||||
|
||||
(function($) {
|
||||
$.fn.unsticky = function() {
|
||||
return this.trigger("unstick.sticky").off(".sticky");
|
||||
};
|
||||
|
||||
$.fn.sticky = function() {
|
||||
var self = this;
|
||||
var stuckToBottom = true;
|
||||
var lastStick = 0;
|
||||
|
||||
var keepToBottom = function() {
|
||||
if (stuckToBottom) {
|
||||
self.scrollBottom();
|
||||
}
|
||||
};
|
||||
|
||||
$(window).on("resize.sticky", keepToBottom);
|
||||
self
|
||||
.on("unstick.sticky", function() {
|
||||
$(window).off("resize.sticky", keepToBottom);
|
||||
})
|
||||
.on("scroll.sticky", function() {
|
||||
// When resizing, sometimes the browser sends a bunch of extra scroll events due to content
|
||||
// reflow, so if we resized within 250ms we can assume it's one of those. The order of said
|
||||
// events is not predictable, and scroll can happen last, so not setting stuckToBottom is
|
||||
// not enough, we have to force the scroll still.
|
||||
if (stuckToBottom && Date.now() - lastStick < 250) {
|
||||
self.scrollBottom();
|
||||
} else {
|
||||
stuckToBottom = self.isScrollBottom();
|
||||
}
|
||||
})
|
||||
.on("scrollBottom.sticky", function() {
|
||||
stuckToBottom = true;
|
||||
lastStick = Date.now();
|
||||
this.scrollTop = this.scrollHeight;
|
||||
})
|
||||
.on("keepToBottom.sticky", keepToBottom)
|
||||
.scrollBottom();
|
||||
|
||||
return self;
|
||||
};
|
||||
|
||||
$.fn.scrollBottom = function() {
|
||||
this.trigger("scrollBottom.sticky");
|
||||
return this;
|
||||
};
|
||||
|
||||
$.fn.isScrollBottom = function() {
|
||||
var el = this[0];
|
||||
return el.scrollHeight - el.scrollTop - el.offsetHeight <= 30;
|
||||
};
|
||||
})(jQuery);
|
@ -9,7 +9,15 @@
|
||||
*/
|
||||
|
||||
(function() {
|
||||
document.getElementById("loading-page-message").textContent = "Loading the app…";
|
||||
const msg = document.getElementById("loading-page-message");
|
||||
|
||||
if (msg) {
|
||||
msg.textContent = "Loading the app…";
|
||||
|
||||
document.getElementById("loading-reload").addEventListener("click", function() {
|
||||
location.reload(true);
|
||||
});
|
||||
}
|
||||
|
||||
var displayReload = function displayReload() {
|
||||
var loadingReload = document.getElementById("loading-reload");
|
||||
@ -32,10 +40,6 @@
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
document.getElementById("loading-reload").addEventListener("click", function() {
|
||||
location.reload();
|
||||
});
|
||||
|
||||
window.g_LoungeErrorHandler = function LoungeErrorHandler(e) {
|
||||
var message = document.getElementById("loading-page-message");
|
||||
message.textContent = "An error has occurred that prevented the client from loading correctly.";
|
||||
|
@ -1,36 +1,29 @@
|
||||
"use strict";
|
||||
|
||||
// vendor libraries
|
||||
require("jquery-ui/ui/widgets/sortable");
|
||||
const $ = require("jquery");
|
||||
const moment = require("moment");
|
||||
|
||||
// our libraries
|
||||
require("./libs/jquery/stickyscroll");
|
||||
const slideoutMenu = require("./slideout");
|
||||
const templates = require("../views");
|
||||
const socket = require("./socket");
|
||||
const render = require("./render");
|
||||
require("./socket-events");
|
||||
const storage = require("./localStorage");
|
||||
const utils = require("./utils");
|
||||
require("./webpush");
|
||||
require("./keybinds");
|
||||
require("./clipboard");
|
||||
const contextMenuFactory = require("./contextMenuFactory");
|
||||
|
||||
$(function() {
|
||||
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 chat = $("#chat");
|
||||
|
||||
$(document.body).data("app-name", document.title);
|
||||
|
||||
const viewport = $("#viewport");
|
||||
|
||||
function storeSidebarVisibility(name, state) {
|
||||
storage.set(name, state);
|
||||
|
||||
utils.togglePreviewMoreButtonsIfNeeded();
|
||||
vueApp.$emit("resize");
|
||||
}
|
||||
|
||||
// If sidebar overlay is visible and it is clicked, close the sidebar
|
||||
@ -56,7 +49,6 @@ $(function() {
|
||||
const isOpen = !viewport.hasClass("userlist-open");
|
||||
|
||||
viewport.toggleClass("userlist-open", isOpen);
|
||||
chat.find(".chan.active .chat").trigger("keepToBottom");
|
||||
storeSidebarVisibility("thelounge.state.userlist", isOpen);
|
||||
|
||||
return false;
|
||||
@ -77,82 +69,28 @@ $(function() {
|
||||
});
|
||||
|
||||
viewport.on("click", "#chat .menu", function(e) {
|
||||
e.currentTarget = $(`#sidebar .chan[data-id="${$(this).closest(".chan").data("id")}"]`)[0];
|
||||
e.currentTarget = $(`#sidebar .chan[data-id="${$(this).closest(".chan").attr("data-id")}"]`)[0];
|
||||
return contextMenuFactory.createContextMenu($(this), e).show();
|
||||
});
|
||||
|
||||
function resetInputHeight(input) {
|
||||
input.style.height = "";
|
||||
}
|
||||
|
||||
const input = $("#input")
|
||||
.on("input", function() {
|
||||
const style = window.getComputedStyle(this);
|
||||
const lineHeight = parseFloat(style.lineHeight, 10) || 1;
|
||||
|
||||
// Start by resetting height before computing as scrollHeight does not
|
||||
// decrease when deleting characters
|
||||
resetInputHeight(this);
|
||||
|
||||
// Use scrollHeight to calculate how many lines there are in input, and ceil the value
|
||||
// because some browsers tend to incorrently round the values when using high density
|
||||
// displays or using page zoom feature
|
||||
this.style.height = Math.ceil(this.scrollHeight / lineHeight) * lineHeight + "px";
|
||||
|
||||
chat.find(".chan.active .chat").trigger("keepToBottom"); // fix growing
|
||||
});
|
||||
|
||||
if (navigator.platform.match(/(Mac|iPhone|iPod|iPad)/i)) {
|
||||
$(document.body).addClass("is-apple");
|
||||
}
|
||||
|
||||
$("#form").on("submit", function() {
|
||||
// Triggering click event opens the virtual keyboard on mobile
|
||||
// This can only be called from another interactive event (e.g. button click)
|
||||
input.trigger("click").trigger("focus");
|
||||
|
||||
const target = chat.data("id");
|
||||
const text = input.val();
|
||||
|
||||
if (text.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
input.val("");
|
||||
resetInputHeight(input.get(0));
|
||||
|
||||
if (text.charAt(0) === "/") {
|
||||
const args = text.substr(1).split(" ");
|
||||
const cmd = args.shift().toLowerCase();
|
||||
|
||||
if (typeof utils.inputCommands[cmd] === "function" && utils.inputCommands[cmd](args)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
socket.emit("input", {target, text});
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
chat.on("click", ".inline-channel", function() {
|
||||
viewport.on("click", ".inline-channel", function() {
|
||||
const name = $(this).attr("data-chan");
|
||||
const chan = utils.findCurrentNetworkChan(name);
|
||||
|
||||
if (chan.length) {
|
||||
chan.trigger("click");
|
||||
if (chan) {
|
||||
$(`#sidebar .chan[data-id="${chan.id}"]`).trigger("click");
|
||||
}
|
||||
|
||||
socket.emit("input", {
|
||||
target: chat.data("id"),
|
||||
target: vueApp.activeChannel.channel.id,
|
||||
text: "/join " + name,
|
||||
});
|
||||
});
|
||||
|
||||
chat.on("click", ".condensed-summary .content", function() {
|
||||
$(this).closest(".msg.condensed").toggleClass("closed");
|
||||
});
|
||||
|
||||
const openWindow = function openWindow(e, {keepSidebarOpen, pushState, replaceHistory} = {}) {
|
||||
const self = $(this);
|
||||
const target = self.attr("data-target");
|
||||
@ -165,32 +103,36 @@ $(function() {
|
||||
// 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) {
|
||||
chat.data(
|
||||
"id",
|
||||
self.data("id")
|
||||
);
|
||||
socket.emit(
|
||||
"open",
|
||||
self.data("id")
|
||||
);
|
||||
vueApp.activeChannel = channel;
|
||||
|
||||
sidebar.find(".active")
|
||||
.removeClass("active")
|
||||
.attr("aria-selected", false);
|
||||
|
||||
self.addClass("active")
|
||||
.attr("aria-selected", true)
|
||||
.find(".badge")
|
||||
.attr("data-highlight", 0)
|
||||
.removeClass("highlight")
|
||||
.empty();
|
||||
|
||||
if (sidebar.find(".highlight").length === 0) {
|
||||
utils.toggleNotificationMarkers(false);
|
||||
if (channel) {
|
||||
channel.channel.highlight = 0;
|
||||
channel.channel.unread = 0;
|
||||
}
|
||||
|
||||
socket.emit("open", channel ? channel.channel.id : null);
|
||||
|
||||
if (!keepSidebarOpen && $(window).outerWidth() <= utils.mobileViewportPixels) {
|
||||
slideoutMenu.toggle(false);
|
||||
}
|
||||
@ -199,59 +141,33 @@ $(function() {
|
||||
const lastActive = $("#windows > .active");
|
||||
|
||||
lastActive
|
||||
.removeClass("active")
|
||||
.find(".chat")
|
||||
.unsticky();
|
||||
|
||||
const lastActiveChan = lastActive.find(".chan.active");
|
||||
|
||||
if (lastActiveChan.length > 0) {
|
||||
lastActiveChan
|
||||
.removeClass("active")
|
||||
.find(".unread-marker")
|
||||
.data("unread-id", 0)
|
||||
.appendTo(lastActiveChan.find(".messages"));
|
||||
|
||||
render.trimMessageInChannel(lastActiveChan, 100);
|
||||
}
|
||||
.removeClass("active");
|
||||
|
||||
const chan = $(target)
|
||||
.addClass("active")
|
||||
.trigger("show");
|
||||
|
||||
utils.togglePreviewMoreButtonsIfNeeded();
|
||||
utils.updateTitle();
|
||||
|
||||
const type = chan.data("type");
|
||||
let placeholder = "";
|
||||
|
||||
if (type === "channel" || type === "query") {
|
||||
placeholder = `Write to ${chan.attr("aria-label")}`;
|
||||
}
|
||||
|
||||
input
|
||||
.prop("placeholder", placeholder)
|
||||
.attr("aria-label", placeholder);
|
||||
utils.synchronizeNotifiedState();
|
||||
|
||||
if (self.hasClass("chan")) {
|
||||
$("#chat-container").addClass("active");
|
||||
$("#nick").text(self.closest(".network").attr("data-nick"));
|
||||
vueApp.$nextTick(() => $("#chat-container").addClass("active"));
|
||||
}
|
||||
|
||||
const chanChat = chan.find(".chat");
|
||||
|
||||
if (chanChat.length > 0 && type !== "special") {
|
||||
chanChat.sticky();
|
||||
|
||||
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");
|
||||
$("#input").trigger("ontouchstart" in window ? "blur" : "focus");
|
||||
}
|
||||
|
||||
if (chan.data("needsNamesRefresh") === true) {
|
||||
chan.data("needsNamesRefresh", false);
|
||||
socket.emit("names", {target: self.data("id")});
|
||||
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.
|
||||
@ -268,7 +184,7 @@ $(function() {
|
||||
if (self.prop("id")) {
|
||||
state.clickTarget = `#${self.prop("id")}`;
|
||||
} else if (self.hasClass("chan")) {
|
||||
state.clickTarget = `#sidebar .chan[data-id="${self.data("id")}"]`;
|
||||
state.clickTarget = `#sidebar .chan[data-id="${self.attr("data-id")}"]`;
|
||||
} else {
|
||||
state.clickTarget = `#footer button[data-target="${target}"]`;
|
||||
}
|
||||
@ -293,31 +209,9 @@ $(function() {
|
||||
});
|
||||
|
||||
$(document).on("visibilitychange focus click", () => {
|
||||
if (sidebar.find(".highlight").length === 0) {
|
||||
utils.toggleNotificationMarkers(false);
|
||||
}
|
||||
utils.synchronizeNotifiedState();
|
||||
});
|
||||
|
||||
// Compute how many milliseconds are remaining until the next day starts
|
||||
function msUntilNextDay() {
|
||||
return moment().add(1, "day").startOf("day") - moment();
|
||||
}
|
||||
|
||||
// Go through all Today/Yesterday date markers in the DOM and recompute their
|
||||
// labels. When done, restart the timer for the next day.
|
||||
function updateDateMarkers() {
|
||||
$(".date-marker-text[data-label='Today'], .date-marker-text[data-label='Yesterday']")
|
||||
.closest(".date-marker-container")
|
||||
.each(function() {
|
||||
$(this).replaceWith(templates.date_marker({time: $(this).data("time")}));
|
||||
});
|
||||
|
||||
// This should always be 24h later but re-computing exact value just in case
|
||||
setTimeout(updateDateMarkers, msUntilNextDay());
|
||||
}
|
||||
|
||||
setTimeout(updateDateMarkers, msUntilNextDay());
|
||||
|
||||
window.addEventListener("popstate", (e) => {
|
||||
const {state} = e;
|
||||
|
||||
@ -348,4 +242,4 @@ $(function() {
|
||||
|
||||
// Only start opening socket.io connection after all events have been registered
|
||||
socket.open();
|
||||
});
|
||||
};
|
||||
|
@ -1,13 +1,12 @@
|
||||
"use strict";
|
||||
|
||||
const $ = require("jquery");
|
||||
const escapeRegExp = require("lodash/escapeRegExp");
|
||||
const storage = require("./localStorage");
|
||||
const tz = require("./libs/handlebars/tz");
|
||||
const socket = require("./socket");
|
||||
const {vueApp} = require("./vue");
|
||||
require("../js/autocompletion");
|
||||
|
||||
const $windows = $("#windows");
|
||||
const $chat = $("#chat");
|
||||
const $settings = $("#settings");
|
||||
const $theme = $("#theme");
|
||||
const $userStyles = $("#user-specified-css");
|
||||
@ -23,30 +22,15 @@ let $warningUnsupported;
|
||||
let $warningBlocked;
|
||||
|
||||
// Default settings
|
||||
const settings = {
|
||||
syncSettings: false,
|
||||
advanced: false,
|
||||
autocomplete: true,
|
||||
nickPostfix: "",
|
||||
coloredNicks: true,
|
||||
desktopNotifications: false,
|
||||
highlights: [],
|
||||
links: true,
|
||||
motd: true,
|
||||
notification: true,
|
||||
notifyAllMessages: false,
|
||||
showSeconds: false,
|
||||
statusMessages: "condensed",
|
||||
theme: $("#theme").attr("data-server-theme"),
|
||||
media: true,
|
||||
userStyles: "",
|
||||
};
|
||||
const settings = vueApp.settings;
|
||||
|
||||
const noSync = ["syncSettings"];
|
||||
|
||||
// alwaysSync is reserved for things like "highlights".
|
||||
// TODO: figure out how to deal with legacy clients that have different settings.
|
||||
const alwaysSync = [];
|
||||
// alwaysSync is reserved for settings that should be synced
|
||||
// to the server regardless of the clients sync setting.
|
||||
const alwaysSync = [
|
||||
"highlights",
|
||||
];
|
||||
|
||||
// Process usersettings from localstorage.
|
||||
let userSettings = JSON.parse(storage.get("settings")) || false;
|
||||
@ -56,7 +40,13 @@ if (!userSettings) {
|
||||
settings.syncSettings = true;
|
||||
} else {
|
||||
for (const key in settings) {
|
||||
if (userSettings[key] !== undefined) {
|
||||
// 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];
|
||||
}
|
||||
}
|
||||
@ -79,21 +69,12 @@ module.exports = {
|
||||
alwaysSync,
|
||||
noSync,
|
||||
initialized: false,
|
||||
highlightsRE: null,
|
||||
settings,
|
||||
shouldOpenMessagePreview,
|
||||
syncAllSettings,
|
||||
processSetting,
|
||||
initialize,
|
||||
};
|
||||
|
||||
// Due to cyclical dependency, have to require it after exports
|
||||
const autocompletion = require("./autocompletion");
|
||||
|
||||
function shouldOpenMessagePreview(type) {
|
||||
return type === "link" ? settings.links : settings.media;
|
||||
}
|
||||
|
||||
// Updates the checkbox and warning in settings.
|
||||
// When notifications are not supported, this is never called (because
|
||||
// checkbox state can not be changed).
|
||||
@ -109,13 +90,6 @@ function applySetting(name, value) {
|
||||
if (name === "syncSettings" && value) {
|
||||
$syncWarningOverride.hide();
|
||||
$forceSyncButton.hide();
|
||||
} else if (name === "motd") {
|
||||
$chat.toggleClass("hide-" + name, !value);
|
||||
} else if (name === "statusMessages") {
|
||||
$chat.toggleClass("hide-status-messages", value === "hidden");
|
||||
$chat.toggleClass("condensed-status-messages", value === "condensed");
|
||||
} else if (name === "coloredNicks") {
|
||||
$chat.toggleClass("colored-nicks", value);
|
||||
} else if (name === "theme") {
|
||||
value = `themes/${value}.css`;
|
||||
|
||||
@ -124,43 +98,6 @@ function applySetting(name, value) {
|
||||
}
|
||||
} else if (name === "userStyles" && !noCSSparamReg.test(window.location.search)) {
|
||||
$userStyles.html(value);
|
||||
} else if (name === "highlights") {
|
||||
let highlights;
|
||||
|
||||
if (typeof value === "string") {
|
||||
highlights = value.split(",").map(function(h) {
|
||||
return h.trim();
|
||||
});
|
||||
} else {
|
||||
highlights = value;
|
||||
}
|
||||
|
||||
highlights = highlights.filter(function(h) {
|
||||
// Ensure we don't have empty string in the list of highlights
|
||||
// otherwise, users get notifications for everything
|
||||
return h !== "";
|
||||
});
|
||||
// Construct regex with wordboundary for every highlight item
|
||||
const highlightsTokens = highlights.map(function(h) {
|
||||
return escapeRegExp(h);
|
||||
});
|
||||
|
||||
if (highlightsTokens && highlightsTokens.length) {
|
||||
module.exports.highlightsRE = new RegExp(`(?:^| |\t)(?:${highlightsTokens.join("|")})(?:\t| |$)`, "i");
|
||||
} else {
|
||||
module.exports.highlightsRE = null;
|
||||
}
|
||||
} else if (name === "showSeconds") {
|
||||
$chat.find(".msg > .time").each(function() {
|
||||
$(this).text(tz($(this).parent().data("time")));
|
||||
});
|
||||
$chat.toggleClass("show-seconds", value);
|
||||
} else if (name === "autocomplete") {
|
||||
if (value) {
|
||||
autocompletion.enable();
|
||||
} else {
|
||||
autocompletion.disable();
|
||||
}
|
||||
} else if (name === "desktopNotifications") {
|
||||
if (("Notification" in window) && value && Notification.permission !== "granted") {
|
||||
Notification.requestPermission(updateDesktopNotificationStatus);
|
||||
@ -178,25 +115,11 @@ function settingSetEmit(name, value) {
|
||||
|
||||
// When sync is `true` the setting will also be send to the backend for syncing.
|
||||
function updateSetting(name, value, sync) {
|
||||
let storeValue = value;
|
||||
|
||||
// First convert highlights if input is a string.
|
||||
// Otherwise we are comparing the wrong types.
|
||||
if (name === "highlights" && typeof value === "string") {
|
||||
storeValue = value.split(",").map(function(h) {
|
||||
return h.trim();
|
||||
}).filter(function(h) {
|
||||
// Ensure we don't have empty string in the list of highlights
|
||||
// otherwise, users get notifications for everything
|
||||
return h !== "";
|
||||
});
|
||||
}
|
||||
|
||||
const currentOption = settings[name];
|
||||
|
||||
// Only update and process when the setting is actually changed.
|
||||
if (currentOption !== storeValue) {
|
||||
settings[name] = storeValue;
|
||||
if (currentOption !== value) {
|
||||
settings[name] = value;
|
||||
storage.set("settings", JSON.stringify(settings));
|
||||
applySetting(name, value);
|
||||
|
||||
|
@ -1,359 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const $ = require("jquery");
|
||||
const templates = require("../views");
|
||||
const options = require("./options");
|
||||
const renderPreview = require("./renderPreview");
|
||||
const utils = require("./utils");
|
||||
const sorting = require("./sorting");
|
||||
const constants = require("./constants");
|
||||
const condensed = require("./condensed");
|
||||
const JoinChannel = require("./join-channel");
|
||||
const helpers_parse = require("./libs/handlebars/parse");
|
||||
const Userlist = require("./userlist");
|
||||
const storage = require("./localStorage");
|
||||
|
||||
const chat = $("#chat");
|
||||
const sidebar = $("#sidebar");
|
||||
|
||||
require("intersection-observer");
|
||||
|
||||
const historyObserver = window.IntersectionObserver ?
|
||||
new window.IntersectionObserver(loadMoreHistory, {
|
||||
root: chat.get(0),
|
||||
}) : null;
|
||||
|
||||
module.exports = {
|
||||
appendMessage,
|
||||
buildChannelMessages,
|
||||
renderChannel,
|
||||
renderChannelUsers,
|
||||
renderNetworks,
|
||||
trimMessageInChannel,
|
||||
};
|
||||
|
||||
function buildChannelMessages(container, chanId, chanType, messages) {
|
||||
return messages.reduce((docFragment, message) => {
|
||||
appendMessage(docFragment, chanId, chanType, message);
|
||||
return docFragment;
|
||||
}, container);
|
||||
}
|
||||
|
||||
function appendMessage(container, chanId, chanType, msg) {
|
||||
if (utils.lastMessageId < msg.id) {
|
||||
utils.lastMessageId = msg.id;
|
||||
}
|
||||
|
||||
let lastChild = container.children(".msg, .date-marker-container").last();
|
||||
const renderedMessage = buildChatMessage(msg);
|
||||
|
||||
// Check if date changed
|
||||
const msgTime = new Date(msg.time);
|
||||
const prevMsgTime = new Date(lastChild.data("time"));
|
||||
|
||||
// Insert date marker if date changed compared to previous message
|
||||
if (prevMsgTime.toDateString() !== msgTime.toDateString()) {
|
||||
lastChild = $(templates.date_marker({time: msg.time}));
|
||||
container.append(lastChild);
|
||||
}
|
||||
|
||||
// If current window is not a channel or this message is not condensable,
|
||||
// then just append the message to container and be done with it
|
||||
if (msg.self || msg.highlight || constants.condensedTypes.indexOf(msg.type) === -1 || chanType !== "channel") {
|
||||
container.append(renderedMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const obj = {};
|
||||
obj[msg.type] = 1;
|
||||
|
||||
// If the previous message is already condensed,
|
||||
// we just append to it and update text
|
||||
if (lastChild.hasClass("condensed")) {
|
||||
lastChild.append(renderedMessage);
|
||||
condensed.updateText(lastChild, obj);
|
||||
return;
|
||||
}
|
||||
|
||||
// Always create a condensed container
|
||||
const newCondensed = $(templates.msg_condensed({time: msg.time}));
|
||||
|
||||
condensed.updateText(newCondensed, obj);
|
||||
newCondensed.append(renderedMessage);
|
||||
container.append(newCondensed);
|
||||
}
|
||||
|
||||
function buildChatMessage(msg) {
|
||||
const type = msg.type;
|
||||
let template = "msg";
|
||||
|
||||
// See if any of the custom highlight regexes match
|
||||
if (!msg.highlight && !msg.self
|
||||
&& options.highlightsRE
|
||||
&& (type === "message" || type === "notice")
|
||||
&& options.highlightsRE.exec(msg.text)) {
|
||||
msg.highlight = true;
|
||||
}
|
||||
|
||||
if (typeof templates.actions[type] !== "undefined") {
|
||||
template = "msg_action";
|
||||
} else if (type === "unhandled") {
|
||||
template = "msg_unhandled";
|
||||
}
|
||||
|
||||
// Make the MOTDs a little nicer if possible
|
||||
if (msg.type === "motd") {
|
||||
let lines = msg.text.split("\n");
|
||||
|
||||
// If all non-empty lines of the MOTD start with a hyphen (which is common
|
||||
// across MOTDs), remove all the leading hyphens.
|
||||
if (lines.every((line) => line === "" || line[0] === "-")) {
|
||||
lines = lines.map((line) => line.substr(2));
|
||||
}
|
||||
|
||||
// Remove empty lines around the MOTD (but not within it)
|
||||
msg.text = lines
|
||||
.map((line) => line.replace(/\s*$/, ""))
|
||||
.join("\n")
|
||||
.replace(/^[\r\n]+|[\r\n]+$/g, "");
|
||||
}
|
||||
|
||||
const renderedMessage = $(templates[template](msg));
|
||||
const content = renderedMessage.find(".content");
|
||||
|
||||
if (template === "msg_action") {
|
||||
content.html(templates.actions[type](msg));
|
||||
}
|
||||
|
||||
msg.previews.forEach((preview) => {
|
||||
renderPreview(preview, renderedMessage);
|
||||
});
|
||||
|
||||
return renderedMessage;
|
||||
}
|
||||
|
||||
function renderChannel(data) {
|
||||
renderChannelMessages(data);
|
||||
|
||||
if (data.type === "channel") {
|
||||
const users = renderChannelUsers(data);
|
||||
|
||||
Userlist.handleKeybinds(users.find(".search"));
|
||||
}
|
||||
|
||||
if (historyObserver) {
|
||||
historyObserver.observe(chat.find("#chan-" + data.id + " .show-more").get(0));
|
||||
}
|
||||
}
|
||||
|
||||
function renderChannelMessages(data) {
|
||||
const documentFragment = buildChannelMessages($(document.createDocumentFragment()), data.id, data.type, data.messages);
|
||||
const channel = chat.find("#chan-" + data.id + " .messages").append(documentFragment);
|
||||
|
||||
renderUnreadMarker($(templates.unread_marker()), data.firstUnread, channel);
|
||||
}
|
||||
|
||||
function renderUnreadMarker(template, firstUnread, channel) {
|
||||
if (firstUnread > 0) {
|
||||
let first = channel.find("#msg-" + firstUnread);
|
||||
|
||||
if (!first.length) {
|
||||
template.data("unread-id", firstUnread);
|
||||
channel.prepend(template);
|
||||
} else {
|
||||
const parent = first.parent();
|
||||
|
||||
if (parent.hasClass("condensed")) {
|
||||
first = parent;
|
||||
}
|
||||
|
||||
first.before(template);
|
||||
}
|
||||
} else {
|
||||
channel.append(template);
|
||||
}
|
||||
}
|
||||
|
||||
function renderChannelUsers(data) {
|
||||
const users = chat.find("#chan-" + data.id).find(".userlist");
|
||||
const nicks = data.users
|
||||
.concat() // Make a copy of the user list, sort is applied in-place
|
||||
.sort((a, b) => b.lastMessage - a.lastMessage)
|
||||
.map((a) => a.nick);
|
||||
|
||||
// Before re-rendering the list of names, there might have been an entry
|
||||
// marked as active (i.e. that was highlighted by keyboard navigation).
|
||||
// It is `undefined` if there was none.
|
||||
const previouslyActive = users.find(".active");
|
||||
|
||||
const search = users
|
||||
.find(".search")
|
||||
.prop("placeholder", nicks.length + " " + (nicks.length === 1 ? "user" : "users"));
|
||||
|
||||
users
|
||||
.data("nicks", nicks)
|
||||
.find(".names-original")
|
||||
.html(templates.user(data));
|
||||
|
||||
// Refresh user search
|
||||
if (search.val().length) {
|
||||
search.trigger("input");
|
||||
}
|
||||
|
||||
// If a nick was highlighted before re-rendering the lists, re-highlight it in
|
||||
// the newly-rendered list.
|
||||
if (previouslyActive.length > 0) {
|
||||
// We need to un-highlight everything first because triggering `input` with
|
||||
// a value highlights the first entry.
|
||||
users.find(".user").removeClass("active");
|
||||
users.find(`.user[data-name="${previouslyActive.attr("data-name")}"]`).addClass("active");
|
||||
}
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
function renderNetworks(data, singleNetwork) {
|
||||
const collapsed = new Set(JSON.parse(storage.get("thelounge.networks.collapsed")));
|
||||
|
||||
sidebar.find(".empty").hide();
|
||||
sidebar.find(".networks").append(
|
||||
templates.network({
|
||||
networks: data.networks,
|
||||
}).trim()
|
||||
);
|
||||
|
||||
// Add keyboard handlers to the "Join a channel…" form inputs/button
|
||||
JoinChannel.handleKeybinds(data.networks);
|
||||
|
||||
let newChannels;
|
||||
const channels = $.map(data.networks, function(n) {
|
||||
if (collapsed.has(n.uuid)) {
|
||||
collapseNetwork($(`.network[data-uuid="${n.uuid}"] button.collapse-network`));
|
||||
}
|
||||
|
||||
return n.channels;
|
||||
});
|
||||
|
||||
if (!singleNetwork && utils.lastMessageId > -1) {
|
||||
newChannels = [];
|
||||
|
||||
channels.forEach((channel) => {
|
||||
const chan = $("#chan-" + channel.id);
|
||||
|
||||
if (chan.length > 0) {
|
||||
if (chan.data("type") === "channel") {
|
||||
chan
|
||||
.data("needsNamesRefresh", true)
|
||||
.find(".header .topic")
|
||||
.html(helpers_parse(channel.topic))
|
||||
.prop("title", channel.topic);
|
||||
}
|
||||
|
||||
if (channel.messages.length > 0) {
|
||||
const container = chan.find(".messages");
|
||||
buildChannelMessages(container, channel.id, channel.type, channel.messages);
|
||||
|
||||
const unreadMarker = container.find(".unread-marker").data("unread-id", 0);
|
||||
renderUnreadMarker(unreadMarker, channel.firstUnread, container);
|
||||
|
||||
if (container.find(".msg").length >= 100) {
|
||||
container.find(".show-more").addClass("show");
|
||||
}
|
||||
|
||||
container.parent().trigger("keepToBottom");
|
||||
}
|
||||
} else {
|
||||
newChannels.push(channel);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
newChannels = channels;
|
||||
}
|
||||
|
||||
if (newChannels.length > 0) {
|
||||
chat.append(
|
||||
templates.chat({
|
||||
channels: newChannels,
|
||||
})
|
||||
);
|
||||
|
||||
newChannels.forEach((channel) => {
|
||||
renderChannel(channel);
|
||||
|
||||
if (channel.type === "channel") {
|
||||
chat.find("#chan-" + channel.id).data("needsNamesRefresh", true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
utils.confirmExit();
|
||||
sorting();
|
||||
|
||||
if (sidebar.find(".highlight").length) {
|
||||
utils.toggleNotificationMarkers(true);
|
||||
}
|
||||
}
|
||||
|
||||
function trimMessageInChannel(channel, messageLimit) {
|
||||
const messages = channel.find(".messages .msg").slice(0, -messageLimit);
|
||||
|
||||
if (messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
messages.remove();
|
||||
|
||||
channel.find(".show-more").addClass("show");
|
||||
|
||||
// Remove date-separators that would otherwise be "stuck" at the top of the channel
|
||||
channel.find(".date-marker-container").each(function() {
|
||||
if ($(this).next().hasClass("date-marker-container")) {
|
||||
$(this).remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function loadMoreHistory(entries) {
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.isIntersecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = $(entry.target).find("button");
|
||||
|
||||
if (target.prop("disabled")) {
|
||||
return;
|
||||
}
|
||||
|
||||
target.trigger("click");
|
||||
});
|
||||
}
|
||||
|
||||
sidebar.on("click", "button.collapse-network", (e) => collapseNetwork($(e.target)));
|
||||
|
||||
function collapseNetwork(target) {
|
||||
const collapseButton = target.closest(".collapse-network");
|
||||
const networks = new Set(JSON.parse(storage.get("thelounge.networks.collapsed")));
|
||||
const networkuuid = collapseButton.closest(".network").data("uuid");
|
||||
|
||||
if (collapseButton.closest(".network").find(".active").length > 0) {
|
||||
collapseButton.closest(".lobby").trigger("click", {
|
||||
keepSidebarOpen: true,
|
||||
});
|
||||
}
|
||||
|
||||
collapseButton.closest(".network").toggleClass("collapsed");
|
||||
|
||||
if (collapseButton.attr("aria-expanded") === "true") {
|
||||
collapseButton.attr("aria-expanded", false);
|
||||
collapseButton.attr("aria-label", "Expand");
|
||||
networks.add(networkuuid);
|
||||
} else {
|
||||
collapseButton.attr("aria-expanded", true);
|
||||
collapseButton.attr("aria-label", "Collapse");
|
||||
networks.delete(networkuuid);
|
||||
}
|
||||
|
||||
storage.set("thelounge.networks.collapsed", JSON.stringify([...networks]));
|
||||
return false;
|
||||
}
|
@ -1,165 +1,8 @@
|
||||
"use strict";
|
||||
|
||||
const $ = require("jquery");
|
||||
const debounce = require("lodash/debounce");
|
||||
const Mousetrap = require("mousetrap");
|
||||
|
||||
const options = require("./options");
|
||||
const socket = require("./socket");
|
||||
const templates = require("../views");
|
||||
const chat = $("#chat");
|
||||
|
||||
const {togglePreviewMoreButtonsIfNeeded} = require("./utils");
|
||||
|
||||
module.exports = renderPreview;
|
||||
|
||||
function renderPreview(preview, msg) {
|
||||
if (preview.type === "loading") {
|
||||
return;
|
||||
}
|
||||
|
||||
preview.shown = preview.shown && options.shouldOpenMessagePreview(preview.type);
|
||||
|
||||
const template = $(templates.msg_preview({preview}));
|
||||
const image = template.find("img, video, audio").first();
|
||||
|
||||
if (image.length === 0) {
|
||||
return appendPreview(preview, msg, template);
|
||||
}
|
||||
|
||||
const loadEvent = image.prop("tagName") === "IMG" ? "load" : "canplay";
|
||||
|
||||
// If there is an image in preview, wait for it to load before appending it to DOM
|
||||
// This is done to prevent problems keeping scroll to the bottom while images load
|
||||
image.on(`${loadEvent}.preview`, () => {
|
||||
image.off(".preview");
|
||||
|
||||
appendPreview(preview, msg, template);
|
||||
});
|
||||
|
||||
// If the image fails to load, remove it from DOM and still render the preview
|
||||
if (preview.type === "link") {
|
||||
image.on("abort.preview error.preview", () => {
|
||||
image.parent().remove();
|
||||
|
||||
appendPreview(preview, msg, template);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function appendPreview(preview, msg, template) {
|
||||
const escapedLink = preview.link.replace(/["\\]/g, "\\$&");
|
||||
const previewContainer = msg.find(`.preview[data-url="${escapedLink}"]`);
|
||||
|
||||
// This is to fix a very rare case of rendering a preview twice
|
||||
// This happens when a very large amount of messages is being sent to the client
|
||||
// and they get queued, so the `preview` object on the server has time to load before
|
||||
// it actually gets sent to the server, which makes the loaded preview sent twice,
|
||||
// once in `msg` and another in `msg:preview`
|
||||
if (!previewContainer.is(":empty")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = msg.closest(".chat");
|
||||
const channel = container.closest(".chan");
|
||||
const channelId = channel.data("id") || -1;
|
||||
const activeChannelId = chat.find(".chan.active").data("id") || -2;
|
||||
|
||||
msg.find(`.text a[href="${escapedLink}"]`)
|
||||
.first()
|
||||
.after(templates.msg_preview_toggle({preview}).trim());
|
||||
|
||||
previewContainer.append(template);
|
||||
|
||||
const moreBtn = previewContainer.find(".more");
|
||||
const previewContent = previewContainer.find(".toggle-content");
|
||||
|
||||
// Depending on the size of the preview and the text within it, show or hide a
|
||||
// "More" button that allows users to expand without having to open the link.
|
||||
// Warning: Make sure to call this only on active channel, link previews only,
|
||||
// expanded only.
|
||||
const showMoreIfNeeded = () => {
|
||||
const isVisible = moreBtn.is(":visible");
|
||||
const shouldShow = previewContent[0].offsetWidth >= previewContainer[0].offsetWidth;
|
||||
|
||||
if (!isVisible && shouldShow) {
|
||||
moreBtn.show();
|
||||
} else if (isVisible && !shouldShow) {
|
||||
togglePreviewMore(moreBtn, false);
|
||||
moreBtn.hide();
|
||||
}
|
||||
};
|
||||
|
||||
// "More" button only applies on text previews
|
||||
if (preview.type === "link") {
|
||||
// This event is triggered when a side menu is opened/closed, or when the
|
||||
// preview gets expanded/collapsed.
|
||||
previewContent.on("showMoreIfNeeded",
|
||||
() => window.requestAnimationFrame(showMoreIfNeeded)
|
||||
);
|
||||
}
|
||||
|
||||
if (activeChannelId === channelId) {
|
||||
// If this preview is in active channel, hide "More" button if necessary
|
||||
previewContent.trigger("showMoreIfNeeded");
|
||||
|
||||
container.trigger("keepToBottom");
|
||||
}
|
||||
}
|
||||
|
||||
// On resize, previews in the current channel that are expanded need to compute
|
||||
// their "More" button. Debounced handler to avoid performance cost.
|
||||
$(window).on("resize", debounce(togglePreviewMoreButtonsIfNeeded, 150));
|
||||
|
||||
$("#chat").on("click", ".text .toggle-button", function() {
|
||||
const self = $(this);
|
||||
const container = self.closest(".chat");
|
||||
const content = self.closest(".content")
|
||||
.find(`.preview[data-url="${self.data("url")}"] .toggle-content`);
|
||||
const bottom = container.isScrollBottom();
|
||||
|
||||
self.toggleClass("opened");
|
||||
content.toggleClass("show");
|
||||
|
||||
const isExpanded = content.hasClass("show");
|
||||
|
||||
if (isExpanded) {
|
||||
content.trigger("showMoreIfNeeded");
|
||||
}
|
||||
|
||||
// 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", {
|
||||
target: parseInt(self.closest(".chan").data("id"), 10),
|
||||
msgId: parseInt(self.closest(".msg").prop("id").replace("msg-", ""), 10),
|
||||
link: self.data("url"),
|
||||
shown: isExpanded,
|
||||
});
|
||||
|
||||
// If scrollbar was at the bottom before toggling the preview, keep it at the bottom
|
||||
if (bottom) {
|
||||
container.scrollBottom();
|
||||
}
|
||||
});
|
||||
|
||||
$("#chat").on("click", ".toggle-content .more", function() {
|
||||
togglePreviewMore($(this));
|
||||
return false;
|
||||
});
|
||||
|
||||
function togglePreviewMore(moreBtn, state = undefined) {
|
||||
moreBtn.closest(".toggle-content").toggleClass("opened", state);
|
||||
const isExpanded = moreBtn.closest(".toggle-content").hasClass("opened");
|
||||
|
||||
moreBtn.attr("aria-expanded", isExpanded);
|
||||
|
||||
if (isExpanded) {
|
||||
moreBtn.attr("aria-label", moreBtn.data("opened-text"));
|
||||
} else {
|
||||
moreBtn.attr("aria-label", moreBtn.data("closed-text"));
|
||||
}
|
||||
}
|
||||
|
||||
/* Image viewer */
|
||||
|
||||
@ -205,22 +48,22 @@ function openImageViewer(link, {pushState = true} = {}) {
|
||||
|
||||
// Previous image
|
||||
let previousImage = link.closest(".preview").prev(".preview")
|
||||
.find(".toggle-content.show .toggle-thumbnail").last();
|
||||
.find(".toggle-content .toggle-thumbnail").last();
|
||||
|
||||
if (!previousImage.length) {
|
||||
previousImage = link.closest(".msg").prevAll()
|
||||
.find(".toggle-content.show .toggle-thumbnail").last();
|
||||
.find(".toggle-content .toggle-thumbnail").last();
|
||||
}
|
||||
|
||||
previousImage.addClass("previous-image");
|
||||
|
||||
// Next image
|
||||
let nextImage = link.closest(".preview").next(".preview")
|
||||
.find(".toggle-content.show .toggle-thumbnail").first();
|
||||
.find(".toggle-content .toggle-thumbnail").first();
|
||||
|
||||
if (!nextImage.length) {
|
||||
nextImage = link.closest(".msg").nextAll()
|
||||
.find(".toggle-content.show .toggle-thumbnail").first();
|
||||
.find(".toggle-content .toggle-thumbnail").first();
|
||||
}
|
||||
|
||||
nextImage.addClass("next-image");
|
||||
@ -276,7 +119,7 @@ function closeImageViewer({pushState = true} = {}) {
|
||||
if (pushState) {
|
||||
const clickTarget =
|
||||
"#sidebar " +
|
||||
`.chan[data-id="${$("#sidebar .chan.active").data("id")}"]`;
|
||||
`.chan[data-id="${$("#sidebar .chan.active").attr("data-id")}"]`;
|
||||
history.pushState({clickTarget}, null, null);
|
||||
}
|
||||
}
|
||||
|
@ -5,13 +5,15 @@ const socket = require("../socket");
|
||||
const storage = require("../localStorage");
|
||||
const utils = require("../utils");
|
||||
const templates = require("../../views");
|
||||
const {vueApp} = require("../vue");
|
||||
|
||||
socket.on("auth", function(data) {
|
||||
// If we reconnected and serverHash differs, that means the server restarted
|
||||
// And we will reload the page to grab the latest version
|
||||
if (utils.serverHash > -1 && data.serverHash > -1 && data.serverHash !== utils.serverHash) {
|
||||
socket.disconnect();
|
||||
$("#connection-error").text("Server restarted, reloading…");
|
||||
vueApp.isConnected = false;
|
||||
vueApp.currentUserVisibleError = "Server restarted, reloading…";
|
||||
location.reload(true);
|
||||
return;
|
||||
}
|
||||
@ -51,7 +53,8 @@ socket.on("auth", function(data) {
|
||||
if (!data.success) {
|
||||
if (login.length === 0) {
|
||||
socket.disconnect();
|
||||
$("#connection-error").text("Authentication failed, reloading…");
|
||||
vueApp.isConnected = false;
|
||||
vueApp.currentUserVisibleError = "Authentication failed, reloading…";
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
@ -66,8 +69,21 @@ socket.on("auth", function(data) {
|
||||
token = storage.get("token");
|
||||
|
||||
if (token) {
|
||||
$("#loading-page-message, #connection-error").text("Authorizing…");
|
||||
const lastMessage = utils.lastMessageId;
|
||||
vueApp.currentUserVisibleError = "Authorizing…";
|
||||
$("#loading-page-message").text(vueApp.currentUserVisibleError);
|
||||
|
||||
let lastMessage = -1;
|
||||
|
||||
for (const network of vueApp.networks) {
|
||||
for (const chan of network.channels) {
|
||||
for (const msg of chan.messages) {
|
||||
if (msg.id > lastMessage) {
|
||||
lastMessage = msg.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
socket.emit("auth", {user, token, lastMessage});
|
||||
}
|
||||
}
|
||||
@ -80,6 +96,7 @@ socket.on("auth", function(data) {
|
||||
return;
|
||||
}
|
||||
|
||||
$("#loading").remove();
|
||||
$("#footer")
|
||||
.find(".sign-in")
|
||||
.trigger("click", {
|
||||
|
@ -8,6 +8,7 @@ const webpush = require("../webpush");
|
||||
const connect = $("#connect");
|
||||
const utils = require("../utils");
|
||||
const upload = require("../upload");
|
||||
const {vueApp} = require("../vue");
|
||||
|
||||
window.addEventListener("beforeinstallprompt", (installPromptEvent) => {
|
||||
$("#webapp-install-button")
|
||||
@ -24,11 +25,7 @@ window.addEventListener("beforeinstallprompt", (installPromptEvent) => {
|
||||
});
|
||||
|
||||
socket.on("configuration", function(data) {
|
||||
if (data.fileUpload) {
|
||||
$("#upload").show();
|
||||
} else {
|
||||
$("#upload").hide();
|
||||
}
|
||||
vueApp.isFileUploadEnabled = data.fileUpload;
|
||||
|
||||
if (options.initialized) {
|
||||
// Likely a reconnect, request sync for possibly missed settings.
|
||||
@ -52,7 +49,6 @@ socket.on("configuration", function(data) {
|
||||
});
|
||||
|
||||
if (data.fileUpload) {
|
||||
upload.initialize();
|
||||
upload.setMaxFileSize(data.fileUploadMaxFileSize);
|
||||
}
|
||||
|
||||
|
@ -7,6 +7,7 @@ require("./join");
|
||||
require("./more");
|
||||
require("./msg");
|
||||
require("./msg_preview");
|
||||
require("./msg_special");
|
||||
require("./names");
|
||||
require("./network");
|
||||
require("./nick");
|
||||
|
@ -3,35 +3,65 @@
|
||||
const $ = require("jquery");
|
||||
const escape = require("css.escape");
|
||||
const socket = require("../socket");
|
||||
const render = require("../render");
|
||||
const webpush = require("../webpush");
|
||||
const slideoutMenu = require("../slideout");
|
||||
const sidebar = $("#sidebar");
|
||||
const storage = require("../localStorage");
|
||||
const utils = require("../utils");
|
||||
const {vueApp, initChannel} = require("../vue");
|
||||
|
||||
socket.on("init", function(data) {
|
||||
$("#loading-page-message, #connection-error").text("Rendering…");
|
||||
vueApp.currentUserVisibleError = "Rendering…";
|
||||
$("#loading-page-message").text(vueApp.currentUserVisibleError);
|
||||
|
||||
const lastMessageId = utils.lastMessageId;
|
||||
let previousActive = 0;
|
||||
const previousActive = vueApp.activeChannel && vueApp.activeChannel.channel.id;
|
||||
|
||||
if (lastMessageId > -1) {
|
||||
previousActive = sidebar.find(".active").data("id");
|
||||
sidebar.find(".networks").empty();
|
||||
const networks = new Set(JSON.parse(storage.get("thelounge.networks.collapsed")));
|
||||
|
||||
for (const network of data.networks) {
|
||||
network.isCollapsed = networks.has(network.uuid);
|
||||
network.channels.forEach(initChannel);
|
||||
|
||||
const currentNetwork = vueApp.networks.find((n) => n.uuid === network.uuid);
|
||||
|
||||
// If we are reconnecting, merge existing state variables because they don't exist on the server
|
||||
if (!currentNetwork) {
|
||||
network.isJoinChannelShown = false;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
network.isJoinChannelShown = currentNetwork.isJoinChannelShown;
|
||||
|
||||
for (const channel of network.channels) {
|
||||
const currentChannel = currentNetwork.channels.find((c) => c.id === channel.id);
|
||||
|
||||
if (!currentChannel) {
|
||||
continue;
|
||||
}
|
||||
|
||||
channel.scrolledToBottom = currentChannel.scrolledToBottom;
|
||||
channel.pendingMessage = currentChannel.pendingMessage;
|
||||
|
||||
// Reconnection only sends new messages, so merge it on the client
|
||||
// Only concat if server sent us less than 100 messages so we don't introduce gaps
|
||||
if (currentChannel.messages && channel.messages.length < 100) {
|
||||
channel.messages = currentChannel.messages.concat(channel.messages);
|
||||
}
|
||||
|
||||
if (currentChannel.moreHistoryAvailable) {
|
||||
channel.moreHistoryAvailable = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.networks.length === 0) {
|
||||
sidebar.find(".empty").show();
|
||||
} else {
|
||||
render.renderNetworks(data);
|
||||
}
|
||||
vueApp.networks = data.networks;
|
||||
vueApp.isConnected = true;
|
||||
vueApp.currentUserVisibleError = null;
|
||||
|
||||
$("#connection-error").removeClass("shown");
|
||||
$(".show-more button, #input").prop("disabled", false);
|
||||
$("#submit").show();
|
||||
if (!vueApp.initialized) {
|
||||
vueApp.initialized = true;
|
||||
|
||||
if (lastMessageId < 0) {
|
||||
if (data.token) {
|
||||
storage.set("token", data.token);
|
||||
}
|
||||
@ -66,7 +96,10 @@ socket.on("init", function(data) {
|
||||
}
|
||||
}
|
||||
|
||||
openCorrectChannel(previousActive, data.active);
|
||||
vueApp.$nextTick(() => openCorrectChannel(previousActive, data.active));
|
||||
|
||||
utils.confirmExit();
|
||||
utils.synchronizeNotifiedState();
|
||||
});
|
||||
|
||||
function openCorrectChannel(clientActive, serverActive) {
|
||||
|
@ -2,36 +2,20 @@
|
||||
|
||||
const $ = require("jquery");
|
||||
const socket = require("../socket");
|
||||
const render = require("../render");
|
||||
const chat = $("#chat");
|
||||
const templates = require("../../views");
|
||||
const sidebar = $("#sidebar");
|
||||
const {vueApp, initChannel} = require("../vue");
|
||||
|
||||
socket.on("join", function(data) {
|
||||
const id = data.network;
|
||||
const network = sidebar.find(`.network[data-uuid="${id}"]`);
|
||||
const channels = network.children();
|
||||
const position = $(channels[data.index || channels.length - 1]); // Put channel in correct position, or the end if we don't have one
|
||||
const sidebarEntry = templates.chan({
|
||||
channels: [data.chan],
|
||||
});
|
||||
$(sidebarEntry).insertAfter(position);
|
||||
chat.append(
|
||||
templates.chat({
|
||||
channels: [data.chan],
|
||||
})
|
||||
);
|
||||
render.renderChannel(data.chan);
|
||||
initChannel(data.chan);
|
||||
|
||||
vueApp.networks.find((n) => n.uuid === data.network)
|
||||
.channels.splice(data.index || -1, 0, data.chan);
|
||||
|
||||
// Queries do not automatically focus, unless the user did a whois
|
||||
if (data.chan.type === "query" && !data.shouldOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
sidebar.find(".chan")
|
||||
.sort(function(a, b) {
|
||||
return $(a).data("id") - $(b).data("id");
|
||||
})
|
||||
.last()
|
||||
.trigger("click");
|
||||
vueApp.$nextTick(() => {
|
||||
$(`#sidebar .chan[data-id="${data.chan.id}"]`).trigger("click");
|
||||
});
|
||||
});
|
||||
|
@ -1,128 +1,19 @@
|
||||
"use strict";
|
||||
|
||||
const $ = require("jquery");
|
||||
const socket = require("../socket");
|
||||
const render = require("../render");
|
||||
const condensed = require("../condensed");
|
||||
const chat = $("#chat");
|
||||
const {vueApp, findChannel} = require("../vue");
|
||||
|
||||
socket.on("more", function(data) {
|
||||
let chan = chat.find("#chan-" + data.chan);
|
||||
const type = chan.data("type");
|
||||
chan = chan.find(".messages");
|
||||
const channel = findChannel(data.chan);
|
||||
|
||||
// get the scrollable wrapper around messages
|
||||
const scrollable = chan.closest(".chat");
|
||||
const heightOld = chan.height() - scrollable.scrollTop();
|
||||
|
||||
// If there are no more messages to show, just hide the button and do nothing else
|
||||
if (!data.messages.length) {
|
||||
scrollable.find(".show-more").removeClass("show");
|
||||
if (!channel) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove the date marker at the top if date does not change
|
||||
const children = $(chan).children();
|
||||
channel.channel.moreHistoryAvailable = data.moreHistoryAvailable;
|
||||
channel.channel.messages.unshift(...data.messages);
|
||||
|
||||
// Check the top-most element and the one after because
|
||||
// unread and date markers may switch positions
|
||||
for (let i = 0; i <= 1; i++) {
|
||||
const marker = children.eq(i);
|
||||
|
||||
if (marker.hasClass("date-marker-container")) {
|
||||
const msgTime = new Date(data.messages[data.messages.length - 1].time);
|
||||
const prevMsgTime = new Date(marker.data("time"));
|
||||
|
||||
if (prevMsgTime.toDateString() === msgTime.toDateString()) {
|
||||
marker.remove();
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Add the older messages
|
||||
const documentFragment = render.buildChannelMessages($(document.createDocumentFragment()), data.chan, type, data.messages);
|
||||
chan.prepend(documentFragment);
|
||||
|
||||
// Move unread marker to correct spot if needed
|
||||
const unreadMarker = chan.find(".unread-marker");
|
||||
const firstUnread = unreadMarker.data("unread-id");
|
||||
|
||||
if (firstUnread > 0) {
|
||||
let first = chan.find("#msg-" + firstUnread);
|
||||
|
||||
if (!first.length) {
|
||||
chan.prepend(unreadMarker);
|
||||
} else {
|
||||
const parent = first.parent();
|
||||
|
||||
if (parent.hasClass("condensed")) {
|
||||
first = parent;
|
||||
}
|
||||
|
||||
unreadMarker.data("unread-id", 0);
|
||||
|
||||
first.before(unreadMarker);
|
||||
}
|
||||
}
|
||||
|
||||
// Join duplicate condensed messages together
|
||||
const condensedDuplicate = chan.find(".msg.condensed + .msg.condensed");
|
||||
|
||||
if (condensedDuplicate) {
|
||||
const condensedCorrect = condensedDuplicate.prev();
|
||||
|
||||
condensed.updateText(condensedCorrect, condensed.getStoredTypes(condensedDuplicate));
|
||||
|
||||
condensedCorrect
|
||||
.append(condensedDuplicate.find(".msg"))
|
||||
.toggleClass("closed", condensedDuplicate.hasClass("closed"));
|
||||
|
||||
condensedDuplicate.remove();
|
||||
}
|
||||
|
||||
// restore scroll position
|
||||
const position = chan.height() - heightOld;
|
||||
scrollable.finish().scrollTop(position);
|
||||
|
||||
// We have to do this hack due to smooth scrolling in browsers,
|
||||
// as scrollTop does not apply correctly
|
||||
if (window.requestAnimationFrame) {
|
||||
window.requestAnimationFrame(() => scrollable.scrollTop(position));
|
||||
}
|
||||
|
||||
if (data.messages.length !== 100) {
|
||||
scrollable.find(".show-more").removeClass("show");
|
||||
}
|
||||
|
||||
// Swap button text back from its alternative label
|
||||
const showMoreBtn = scrollable.find(".show-more button");
|
||||
swapText(showMoreBtn);
|
||||
showMoreBtn.prop("disabled", false);
|
||||
});
|
||||
|
||||
chat.on("click", ".show-more button", function() {
|
||||
const self = $(this);
|
||||
const lastMessage = self.closest(".chat").find(".msg:not(.condensed)").first();
|
||||
let lastMessageId = -1;
|
||||
|
||||
if (lastMessage.length > 0) {
|
||||
lastMessageId = parseInt(lastMessage.prop("id").replace("msg-", ""), 10);
|
||||
}
|
||||
|
||||
// Swap button text with its alternative label
|
||||
swapText(self);
|
||||
self.prop("disabled", true);
|
||||
|
||||
socket.emit("more", {
|
||||
target: self.data("id"),
|
||||
lastId: lastMessageId,
|
||||
vueApp.$nextTick(() => {
|
||||
channel.channel.historyLoading = false;
|
||||
});
|
||||
});
|
||||
|
||||
// Given a button, swap its text with the content of `data-alt-text`
|
||||
function swapText(btn) {
|
||||
const altText = btn.data("alt-text");
|
||||
btn.data("alt-text", btn.text()).text(altText);
|
||||
}
|
||||
|
@ -2,14 +2,11 @@
|
||||
|
||||
const $ = require("jquery");
|
||||
const socket = require("../socket");
|
||||
const render = require("../render");
|
||||
const utils = require("../utils");
|
||||
const options = require("../options");
|
||||
const helpers_roundBadgeNumber = require("../libs/handlebars/roundBadgeNumber");
|
||||
const cleanIrcMessage = require("../libs/handlebars/ircmessageparser/cleanIrcMessage");
|
||||
const webpush = require("../webpush");
|
||||
const chat = $("#chat");
|
||||
const sidebar = $("#sidebar");
|
||||
const {vueApp, findChannel} = require("../vue");
|
||||
|
||||
let pop;
|
||||
|
||||
@ -23,130 +20,76 @@ try {
|
||||
}
|
||||
|
||||
socket.on("msg", function(data) {
|
||||
// We set a maximum timeout of 2 seconds so that messages don't take too long to appear.
|
||||
utils.requestIdleCallback(() => processReceivedMessage(data), 2000);
|
||||
});
|
||||
const receivingChannel = findChannel(data.chan);
|
||||
|
||||
function processReceivedMessage(data) {
|
||||
let targetId = data.chan;
|
||||
let target = "#chan-" + targetId;
|
||||
let channel = chat.find(target);
|
||||
let sidebarTarget = sidebar.find("[data-target='" + target + "']");
|
||||
if (!receivingChannel) {
|
||||
return;
|
||||
}
|
||||
|
||||
let channel = receivingChannel.channel;
|
||||
const isActiveChannel = vueApp.activeChannel && vueApp.activeChannel.channel === channel;
|
||||
|
||||
// Display received notices and errors in currently active channel.
|
||||
// Reloading the page will put them back into the lobby window.
|
||||
if (data.msg.showInActive) {
|
||||
const activeOnNetwork = sidebarTarget.parent().find(".active");
|
||||
// We only want to put errors/notices in active channel if they arrive on the same network
|
||||
if (data.msg.showInActive && vueApp.activeChannel && vueApp.activeChannel.network === receivingChannel.network) {
|
||||
channel = vueApp.activeChannel.channel;
|
||||
|
||||
// We only want to put errors/notices in active channel if they arrive on the same network
|
||||
if (activeOnNetwork.length > 0) {
|
||||
targetId = data.chan = activeOnNetwork.data("id");
|
||||
data.chan = channel.id;
|
||||
} else if (!isActiveChannel) {
|
||||
// Do not set unread counter for channel if it is currently active on this client
|
||||
// It may increase on the server before it processes channel open event from this client
|
||||
|
||||
target = "#chan-" + targetId;
|
||||
channel = chat.find(target);
|
||||
sidebarTarget = sidebar.find("[data-target='" + target + "']");
|
||||
if (typeof data.highlight !== "undefined") {
|
||||
channel.highlight = data.highlight;
|
||||
}
|
||||
|
||||
if (typeof data.unread !== "undefined") {
|
||||
channel.unread = data.unread;
|
||||
}
|
||||
}
|
||||
|
||||
const scrollContainer = channel.find(".chat");
|
||||
const container = channel.find(".messages");
|
||||
const activeChannelId = chat.find(".chan.active").data("id");
|
||||
channel.messages.push(data.msg);
|
||||
|
||||
if (data.msg.type === "channel_list" || data.msg.type === "ban_list" || data.msg.type === "ignore_list") {
|
||||
$(container).empty();
|
||||
}
|
||||
|
||||
// Add message to the container
|
||||
render.appendMessage(
|
||||
container,
|
||||
targetId,
|
||||
channel.data("type"),
|
||||
data.msg
|
||||
);
|
||||
|
||||
if (activeChannelId === targetId) {
|
||||
scrollContainer.trigger("keepToBottom");
|
||||
}
|
||||
|
||||
notifyMessage(targetId, channel, data);
|
||||
|
||||
let shouldMoveMarker = data.msg.self;
|
||||
|
||||
if (!shouldMoveMarker) {
|
||||
const lastChild = container.children().last();
|
||||
|
||||
// If last element is hidden (e.g. hidden status messages) check the element before it.
|
||||
// If it's unread marker or date marker, then move unread marker to the bottom
|
||||
// so that it isn't displayed as the last element in chat.
|
||||
// display properly is checked instead of using `:hidden` selector because it doesn't work in non-active channels.
|
||||
if (lastChild.css("display") === "none") {
|
||||
const prevChild = lastChild.prev();
|
||||
|
||||
shouldMoveMarker =
|
||||
prevChild.hasClass("unread-marker") ||
|
||||
(prevChild.hasClass("date-marker") && prevChild.prev().hasClass("unread-marker"));
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldMoveMarker) {
|
||||
container
|
||||
.find(".unread-marker")
|
||||
.data("unread-id", 0)
|
||||
.appendTo(container);
|
||||
}
|
||||
|
||||
// Clear unread/highlight counter if self-message
|
||||
if (data.msg.self) {
|
||||
sidebarTarget.find(".badge")
|
||||
.attr("data-highlight", 0)
|
||||
.removeClass("highlight")
|
||||
.empty();
|
||||
|
||||
utils.updateTitle();
|
||||
channel.firstUnread = data.msg.id;
|
||||
} else {
|
||||
notifyMessage(data.chan, channel, vueApp.activeChannel, data.msg);
|
||||
}
|
||||
|
||||
let messageLimit = 0;
|
||||
|
||||
if (activeChannelId !== targetId) {
|
||||
if (!isActiveChannel) {
|
||||
// If message arrives in non active channel, keep only 100 messages
|
||||
messageLimit = 100;
|
||||
} else if (scrollContainer.isScrollBottom()) {
|
||||
} else if (channel.scrolledToBottom) {
|
||||
// If message arrives in active channel, keep 500 messages if scroll is currently at the bottom
|
||||
messageLimit = 500;
|
||||
}
|
||||
|
||||
if (messageLimit > 0) {
|
||||
render.trimMessageInChannel(channel, messageLimit);
|
||||
if (messageLimit > 0 && channel.messages.length > messageLimit) {
|
||||
channel.messages.splice(0, channel.messages.length - messageLimit);
|
||||
channel.moreHistoryAvailable = true;
|
||||
}
|
||||
|
||||
if ((data.msg.type === "message" || data.msg.type === "action") && channel.hasClass("channel")) {
|
||||
const nicks = channel.find(".userlist").data("nicks");
|
||||
if ((data.msg.type === "message" || data.msg.type === "action") && channel.type === "channel") {
|
||||
const user = channel.users.find((u) => u.nick === data.msg.from.nick);
|
||||
|
||||
if (nicks) {
|
||||
const find = nicks.indexOf(data.msg.from.nick);
|
||||
|
||||
if (find !== -1) {
|
||||
nicks.splice(find, 1);
|
||||
nicks.unshift(data.msg.from.nick);
|
||||
}
|
||||
if (user) {
|
||||
user.lastMessage = (new Date(data.msg.time)).getTime() || Date.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function notifyMessage(targetId, channel, msg) {
|
||||
const serverUnread = msg.unread;
|
||||
const serverHighlight = msg.highlight;
|
||||
|
||||
msg = msg.msg;
|
||||
|
||||
if (msg.self) {
|
||||
return;
|
||||
if (data.msg.self || data.msg.highlight) {
|
||||
utils.synchronizeNotifiedState();
|
||||
}
|
||||
});
|
||||
|
||||
const button = sidebar.find(".chan[data-id='" + targetId + "']");
|
||||
function notifyMessage(targetId, channel, activeChannel, msg) {
|
||||
const button = $("#sidebar .chan[data-id='" + targetId + "']");
|
||||
|
||||
if (msg.highlight || (options.settings.notifyAllMessages && msg.type === "message")) {
|
||||
if (!document.hasFocus() || !channel.hasClass("active")) {
|
||||
if (!document.hasFocus() || !activeChannel || activeChannel.channel !== channel) {
|
||||
if (options.settings.notification) {
|
||||
try {
|
||||
pop.play();
|
||||
@ -155,8 +98,6 @@ function notifyMessage(targetId, channel, msg) {
|
||||
}
|
||||
}
|
||||
|
||||
utils.toggleNotificationMarkers(true);
|
||||
|
||||
if (options.settings.desktopNotifications && ("Notification" in window) && Notification.permission === "granted") {
|
||||
let title;
|
||||
let body;
|
||||
@ -167,8 +108,8 @@ function notifyMessage(targetId, channel, msg) {
|
||||
} else {
|
||||
title = msg.from.nick;
|
||||
|
||||
if (!button.hasClass("query")) {
|
||||
title += " (" + button.attr("aria-label").trim() + ")";
|
||||
if (channel.type !== "query") {
|
||||
title += ` (${channel.name})`;
|
||||
}
|
||||
|
||||
if (msg.type === "message") {
|
||||
@ -211,18 +152,4 @@ function notifyMessage(targetId, channel, msg) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!serverUnread || button.hasClass("active")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const badge = button.find(".badge")
|
||||
.attr("data-highlight", serverHighlight)
|
||||
.html(helpers_roundBadgeNumber(serverUnread));
|
||||
|
||||
if (msg.highlight) {
|
||||
badge.addClass("highlight");
|
||||
|
||||
utils.updateTitle();
|
||||
}
|
||||
}
|
||||
|
@ -1,11 +1,19 @@
|
||||
"use strict";
|
||||
|
||||
const $ = require("jquery");
|
||||
const renderPreview = require("../renderPreview");
|
||||
const socket = require("../socket");
|
||||
const utils = require("../utils");
|
||||
const {vueApp, findChannel} = require("../vue");
|
||||
|
||||
socket.on("msg:preview", function(data) {
|
||||
// Previews are not as important, we can wait longer for them to appear
|
||||
utils.requestIdleCallback(() => renderPreview(data.preview, $("#msg-" + data.id)), 6000);
|
||||
const {channel} = findChannel(data.chan);
|
||||
const message = channel.messages.find((m) => m.id === data.id);
|
||||
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previewIndex = message.previews.findIndex((m) => m.link === data.preview.link);
|
||||
|
||||
if (previewIndex > -1) {
|
||||
vueApp.$set(message.previews, previewIndex, data.preview);
|
||||
}
|
||||
});
|
||||
|
8
client/js/socket-events/msg_special.js
Normal file
8
client/js/socket-events/msg_special.js
Normal file
@ -0,0 +1,8 @@
|
||||
"use strict";
|
||||
|
||||
const socket = require("../socket");
|
||||
const {findChannel} = require("../vue");
|
||||
|
||||
socket.on("msg:special", function(data) {
|
||||
findChannel(data.chan).channel.data = data.data;
|
||||
});
|
@ -1,6 +1,12 @@
|
||||
"use strict";
|
||||
|
||||
const socket = require("../socket");
|
||||
const render = require("../render");
|
||||
const {findChannel} = require("../vue");
|
||||
|
||||
socket.on("names", render.renderChannelUsers);
|
||||
socket.on("names", function(data) {
|
||||
const channel = findChannel(data.id);
|
||||
|
||||
if (channel) {
|
||||
channel.channel.users = data.users;
|
||||
}
|
||||
});
|
||||
|
@ -2,32 +2,59 @@
|
||||
|
||||
const $ = require("jquery");
|
||||
const socket = require("../socket");
|
||||
const render = require("../render");
|
||||
const templates = require("../../views");
|
||||
const sidebar = $("#sidebar");
|
||||
const utils = require("../utils");
|
||||
const {vueApp, initChannel, findChannel} = require("../vue");
|
||||
|
||||
socket.on("network", function(data) {
|
||||
render.renderNetworks(data, true);
|
||||
const network = data.networks[0];
|
||||
|
||||
sidebar.find(".chan")
|
||||
.last()
|
||||
.trigger("click");
|
||||
network.isJoinChannelShown = false;
|
||||
network.isCollapsed = false;
|
||||
network.channels.forEach(initChannel);
|
||||
|
||||
vueApp.networks.push(network);
|
||||
|
||||
vueApp.$nextTick(() => {
|
||||
sidebar.find(".chan")
|
||||
.last()
|
||||
.trigger("click");
|
||||
});
|
||||
|
||||
$("#connect")
|
||||
.find(".btn")
|
||||
.prop("disabled", false);
|
||||
});
|
||||
|
||||
socket.on("network_changed", function(data) {
|
||||
sidebar.find(`.network[data-uuid="${data.network}"]`).data("options", data.serverOptions);
|
||||
socket.on("network:options", function(data) {
|
||||
vueApp.networks.find((n) => n.uuid === data.network).serverOptions = data.serverOptions;
|
||||
});
|
||||
|
||||
socket.on("network:status", function(data) {
|
||||
sidebar
|
||||
.find(`.network[data-uuid="${data.network}"]`)
|
||||
.toggleClass("not-connected", !data.connected)
|
||||
.toggleClass("not-secure", !data.secure);
|
||||
const network = vueApp.networks.find((n) => n.uuid === data.network);
|
||||
|
||||
if (!network) {
|
||||
return;
|
||||
}
|
||||
|
||||
network.status.connected = data.connected;
|
||||
network.status.secure = data.secure;
|
||||
|
||||
if (!data.connected) {
|
||||
network.channels.forEach((channel) => {
|
||||
channel.users = [];
|
||||
channel.state = 0;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("channel:state", function(data) {
|
||||
const channel = findChannel(data.chan);
|
||||
|
||||
if (channel) {
|
||||
channel.channel.state = data.state;
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("network:info", function(data) {
|
||||
@ -37,9 +64,10 @@ socket.on("network:info", function(data) {
|
||||
const uuid = $(this).find("input[name=uuid]").val();
|
||||
const newName = $(this).find("#connect\\:name").val();
|
||||
|
||||
const network = vueApp.networks.find((n) => n.uuid === uuid);
|
||||
network.name = network.channels[0].name = newName;
|
||||
|
||||
sidebar.find(`.network[data-uuid="${uuid}"] .chan.lobby .name`)
|
||||
.attr("title", newName)
|
||||
.text(newName)
|
||||
.click();
|
||||
});
|
||||
|
||||
|
@ -1,14 +1,12 @@
|
||||
"use strict";
|
||||
|
||||
const $ = require("jquery");
|
||||
const socket = require("../socket");
|
||||
const {vueApp} = require("../vue");
|
||||
|
||||
socket.on("nick", function(data) {
|
||||
const id = data.network;
|
||||
const nick = data.nick;
|
||||
const network = $(`#sidebar .network[data-uuid="${id}"]`).attr("data-nick", nick);
|
||||
const network = vueApp.networks.find((n) => n.uuid === data.network);
|
||||
|
||||
if (network.find(".active").length) {
|
||||
$("#nick").text(nick);
|
||||
if (network) {
|
||||
network.nick = data.nick;
|
||||
}
|
||||
});
|
||||
|
@ -1,8 +1,8 @@
|
||||
"use strict";
|
||||
|
||||
const $ = require("jquery");
|
||||
const socket = require("../socket");
|
||||
const utils = require("../utils");
|
||||
const {vueApp, findChannel} = require("../vue");
|
||||
|
||||
// Sync unread badge and marker when other clients open a channel
|
||||
socket.on("open", function(id) {
|
||||
@ -10,24 +10,22 @@ socket.on("open", function(id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = $("#chat #chan-" + id);
|
||||
|
||||
// Don't do anything if the channel is active on this client
|
||||
if (channel.length === 0 || channel.hasClass("active")) {
|
||||
if (vueApp.activeChannel && vueApp.activeChannel.channel.id === id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear the unread badge
|
||||
$("#sidebar").find(".chan[data-id='" + id + "'] .badge")
|
||||
.attr("data-highlight", 0)
|
||||
.removeClass("highlight")
|
||||
.empty();
|
||||
const channel = findChannel(id);
|
||||
|
||||
utils.updateTitle();
|
||||
if (channel) {
|
||||
channel.channel.highlight = 0;
|
||||
channel.channel.unread = 0;
|
||||
|
||||
// Move unread marker to the bottom
|
||||
channel
|
||||
.find(".unread-marker")
|
||||
.data("unread-id", 0)
|
||||
.appendTo(channel.find(".messages"));
|
||||
if (channel.channel.messages.length > 0) {
|
||||
channel.channel.firstUnread = channel.channel.messages[channel.channel.messages.length - 1].id;
|
||||
}
|
||||
}
|
||||
|
||||
utils.synchronizeNotifiedState();
|
||||
});
|
||||
|
@ -2,19 +2,23 @@
|
||||
|
||||
const $ = require("jquery");
|
||||
const socket = require("../socket");
|
||||
const sidebar = $("#sidebar");
|
||||
const utils = require("../utils");
|
||||
const {vueApp, findChannel} = require("../vue");
|
||||
|
||||
socket.on("part", function(data) {
|
||||
const chanMenuItem = sidebar.find(".chan[data-id='" + data.chan + "']");
|
||||
|
||||
// When parting from the active channel/query, jump to the network's lobby
|
||||
if (chanMenuItem.hasClass("active")) {
|
||||
chanMenuItem
|
||||
.parent(".network")
|
||||
if (vueApp.activeChannel && vueApp.activeChannel.channel.id === data.chan) {
|
||||
$("#sidebar .chan[data-id='" + data.chan + "']")
|
||||
.closest(".network")
|
||||
.find(".lobby")
|
||||
.trigger("click");
|
||||
}
|
||||
|
||||
chanMenuItem.remove();
|
||||
$("#chan-" + data.chan).remove();
|
||||
const channel = findChannel(data.chan);
|
||||
|
||||
if (channel) {
|
||||
channel.network.channels.splice(channel.network.channels.findIndex((c) => c.id === data.chan), 1);
|
||||
}
|
||||
|
||||
utils.synchronizeNotifiedState();
|
||||
});
|
||||
|
@ -1,31 +1,23 @@
|
||||
"use strict";
|
||||
|
||||
const $ = require("jquery");
|
||||
const chat = $("#chat");
|
||||
const socket = require("../socket");
|
||||
const sidebar = $("#sidebar");
|
||||
const {vueApp} = require("../vue");
|
||||
|
||||
socket.on("quit", function(data) {
|
||||
const id = data.network;
|
||||
const network = sidebar.find(`.network[data-uuid="${id}"]`);
|
||||
vueApp.networks.splice(vueApp.networks.findIndex((n) => n.uuid === data.network), 1);
|
||||
|
||||
network.children(".chan").each(function() {
|
||||
// this = child
|
||||
chat.find($(this).attr("data-target")).remove();
|
||||
vueApp.$nextTick(() => {
|
||||
const chan = sidebar.find(".chan");
|
||||
|
||||
if (chan.length === 0) {
|
||||
// Open the connect window
|
||||
$("#footer .connect").trigger("click", {
|
||||
pushState: false,
|
||||
});
|
||||
} else {
|
||||
chan.eq(0).trigger("click");
|
||||
}
|
||||
});
|
||||
|
||||
network.remove();
|
||||
|
||||
const chan = sidebar.find(".chan");
|
||||
|
||||
if (chan.length === 0) {
|
||||
sidebar.find(".empty").show();
|
||||
|
||||
// Open the connect window
|
||||
$("#footer .connect").trigger("click", {
|
||||
pushState: false,
|
||||
});
|
||||
} else {
|
||||
chan.eq(0).trigger("click");
|
||||
}
|
||||
});
|
||||
|
@ -2,15 +2,8 @@
|
||||
|
||||
const $ = require("jquery");
|
||||
const socket = require("../socket");
|
||||
const options = require("../options");
|
||||
|
||||
socket.on("sync_sort", function(data) {
|
||||
// Syncs the order of channels or networks when they are reordered
|
||||
if (options.ignoreSortSync) {
|
||||
options.ignoreSortSync = false;
|
||||
return; // Ignore syncing because we 'caused' it
|
||||
}
|
||||
|
||||
const type = data.type;
|
||||
const order = data.order;
|
||||
const container = $(".networks");
|
||||
@ -20,7 +13,7 @@ socket.on("sync_sort", function(data) {
|
||||
$.each(order, function(index, value) {
|
||||
const position = $(container.children(".network")[index]);
|
||||
|
||||
if (position.data("id") === value) { // Network in correct place
|
||||
if (Number(position.attr("data-id")) === value) { // Network in correct place
|
||||
return true; // No point in continuing
|
||||
}
|
||||
|
||||
@ -34,7 +27,7 @@ socket.on("sync_sort", function(data) {
|
||||
|
||||
const position = $(network.children(".chan")[index]); // Target channel at position
|
||||
|
||||
if (position.data("id") === value) { // Channel in correct place
|
||||
if (Number(position.attr("data-id")) === value) { // Channel in correct place
|
||||
return true; // No point in continuing
|
||||
}
|
||||
|
||||
|
@ -1,12 +1,12 @@
|
||||
"use strict";
|
||||
|
||||
const $ = require("jquery");
|
||||
const socket = require("../socket");
|
||||
const helpers_parse = require("../libs/handlebars/parse");
|
||||
const {findChannel} = require("../vue");
|
||||
|
||||
socket.on("topic", function(data) {
|
||||
const topic = $("#chan-" + data.chan).find(".header .topic");
|
||||
topic.html(helpers_parse(data.topic));
|
||||
// .prop() is safe escape-wise but consider the capabilities of the attribute
|
||||
topic.prop("title", data.topic);
|
||||
const channel = findChannel(data.chan);
|
||||
|
||||
if (channel) {
|
||||
channel.channel.topic = data.topic;
|
||||
}
|
||||
});
|
||||
|
@ -1,17 +1,18 @@
|
||||
"use strict";
|
||||
|
||||
const $ = require("jquery");
|
||||
const socket = require("../socket");
|
||||
const chat = $("#chat");
|
||||
const {vueApp, findChannel} = require("../vue");
|
||||
|
||||
socket.on("users", function(data) {
|
||||
const chan = chat.find("#chan-" + data.chan);
|
||||
|
||||
if (chan.hasClass("active")) {
|
||||
socket.emit("names", {
|
||||
if (vueApp.activeChannel && vueApp.activeChannel.channel.id === data.chan) {
|
||||
return socket.emit("names", {
|
||||
target: data.chan,
|
||||
});
|
||||
} else {
|
||||
chan.data("needsNamesRefresh", true);
|
||||
}
|
||||
|
||||
const channel = findChannel(data.chan);
|
||||
|
||||
if (channel) {
|
||||
channel.channel.usersOutdated = true;
|
||||
}
|
||||
});
|
||||
|
@ -2,7 +2,6 @@
|
||||
|
||||
const $ = require("jquery");
|
||||
const io = require("socket.io-client");
|
||||
const utils = require("./utils");
|
||||
|
||||
const socket = io({
|
||||
transports: $(document.body).data("transports"),
|
||||
@ -11,20 +10,23 @@ const socket = io({
|
||||
reconnection: !$(document.body).hasClass("public"),
|
||||
});
|
||||
|
||||
$("#connection-error").on("click", function() {
|
||||
$(this).removeClass("shown");
|
||||
});
|
||||
module.exports = socket;
|
||||
|
||||
const {vueApp} = require("./vue");
|
||||
const {requestIdleCallback} = require("./utils");
|
||||
|
||||
socket.on("disconnect", handleDisconnect);
|
||||
socket.on("connect_error", handleDisconnect);
|
||||
socket.on("error", handleDisconnect);
|
||||
|
||||
socket.on("reconnecting", function(attempt) {
|
||||
$("#loading-page-message, #connection-error").text(`Reconnecting… (attempt ${attempt})`);
|
||||
vueApp.currentUserVisibleError = `Reconnecting… (attempt ${attempt})`;
|
||||
$("#loading-page-message").text(vueApp.currentUserVisibleError);
|
||||
});
|
||||
|
||||
socket.on("connecting", function() {
|
||||
$("#loading-page-message, #connection-error").text("Connecting…");
|
||||
vueApp.currentUserVisibleError = "Connecting…";
|
||||
$("#loading-page-message").text(vueApp.currentUserVisibleError);
|
||||
});
|
||||
|
||||
socket.on("connect", function() {
|
||||
@ -33,26 +35,25 @@ socket.on("connect", function() {
|
||||
// nothing is sent to the server that might have happened.
|
||||
socket.sendBuffer = [];
|
||||
|
||||
$("#loading-page-message, #connection-error").text("Finalizing connection…");
|
||||
vueApp.currentUserVisibleError = "Finalizing connection…";
|
||||
$("#loading-page-message").text(vueApp.currentUserVisibleError);
|
||||
});
|
||||
|
||||
socket.on("authorized", function() {
|
||||
$("#loading-page-message, #connection-error").text("Loading messages…");
|
||||
vueApp.currentUserVisibleError = "Loading messages…";
|
||||
$("#loading-page-message").text(vueApp.currentUserVisibleError);
|
||||
});
|
||||
|
||||
function handleDisconnect(data) {
|
||||
const message = data.message || data;
|
||||
|
||||
$("#loading-page-message, #connection-error").text(`Waiting to reconnect… (${message})`).addClass("shown");
|
||||
$(".show-more button, #input").prop("disabled", true);
|
||||
$("#submit").hide();
|
||||
$("#upload").hide();
|
||||
vueApp.isConnected = false;
|
||||
vueApp.currentUserVisibleError = `Waiting to reconnect… (${message})`;
|
||||
$("#loading-page-message").text(vueApp.currentUserVisibleError);
|
||||
|
||||
// If the server shuts down, socket.io skips reconnection
|
||||
// and we have to manually call connect to start the process
|
||||
if (socket.io.skipReconnect) {
|
||||
utils.requestIdleCallback(() => socket.connect(), 2000);
|
||||
requestIdleCallback(() => socket.connect(), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = socket;
|
||||
|
@ -1,64 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const $ = require("jquery");
|
||||
const sidebar = $("#sidebar, #footer");
|
||||
const socket = require("./socket");
|
||||
const options = require("./options");
|
||||
|
||||
module.exports = function() {
|
||||
sidebar.find(".networks").sortable({
|
||||
axis: "y",
|
||||
containment: "parent",
|
||||
cursor: "move",
|
||||
distance: 12,
|
||||
items: ".network",
|
||||
handle: ".lobby",
|
||||
placeholder: "network-placeholder",
|
||||
forcePlaceholderSize: true,
|
||||
tolerance: "pointer", // Use the pointer to figure out where the network is in the list
|
||||
|
||||
update() {
|
||||
const order = [];
|
||||
|
||||
sidebar.find(".network").each(function() {
|
||||
const id = $(this).data("uuid");
|
||||
order.push(id);
|
||||
});
|
||||
|
||||
socket.emit("sort", {
|
||||
type: "networks",
|
||||
order: order,
|
||||
});
|
||||
|
||||
options.settings.ignoreSortSync = true;
|
||||
},
|
||||
});
|
||||
sidebar.find(".network").sortable({
|
||||
axis: "y",
|
||||
containment: "parent",
|
||||
cursor: "move",
|
||||
distance: 12,
|
||||
items: ".chan:not(.lobby)",
|
||||
placeholder: "chan-placeholder",
|
||||
forcePlaceholderSize: true,
|
||||
tolerance: "pointer", // Use the pointer to figure out where the channel is in the list
|
||||
|
||||
update(e, ui) {
|
||||
const order = [];
|
||||
const network = ui.item.parent();
|
||||
|
||||
network.find(".chan").each(function() {
|
||||
const id = $(this).data("id");
|
||||
order.push(id);
|
||||
});
|
||||
|
||||
socket.emit("sort", {
|
||||
type: "channels",
|
||||
target: network.data("uuid"),
|
||||
order: order,
|
||||
});
|
||||
|
||||
options.settings.ignoreSortSync = true;
|
||||
},
|
||||
});
|
||||
};
|
@ -4,15 +4,12 @@ const $ = require("jquery");
|
||||
const socket = require("./socket");
|
||||
const SocketIOFileUpload = require("socketio-file-upload/client");
|
||||
const instance = new SocketIOFileUpload(socket);
|
||||
const {vueApp} = require("./vue");
|
||||
|
||||
function initialize() {
|
||||
instance.listenOnInput(document.getElementById("upload-input"));
|
||||
instance.listenOnDrop(document);
|
||||
|
||||
$("#upload").on("click", () => {
|
||||
$("#upload-input").trigger("click");
|
||||
});
|
||||
|
||||
instance.addEventListener("complete", () => {
|
||||
// Reset progressbar
|
||||
$("#upload-progressbar").width(0);
|
||||
@ -26,7 +23,7 @@ function initialize() {
|
||||
instance.addEventListener("error", (event) => {
|
||||
// Reset progressbar
|
||||
$("#upload-progressbar").width(0);
|
||||
$("#connection-error").addClass("shown").text(event.message);
|
||||
vueApp.currentUserVisibleError = event.message;
|
||||
});
|
||||
|
||||
const $form = $(document);
|
||||
|
@ -1,117 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const $ = require("jquery");
|
||||
const fuzzy = require("fuzzy");
|
||||
const Mousetrap = require("mousetrap");
|
||||
|
||||
const templates = require("../views");
|
||||
const utils = require("./utils");
|
||||
|
||||
const chat = $("#chat");
|
||||
|
||||
chat.on("input", ".userlist .search", function() {
|
||||
const value = $(this).val();
|
||||
const parent = $(this).closest(".userlist");
|
||||
const names = parent.find(".names-original");
|
||||
const container = parent.find(".names-filtered");
|
||||
|
||||
// Input content has changed, reset the potential selection
|
||||
parent.find(".user").removeClass("active");
|
||||
|
||||
if (!value.length) {
|
||||
container.hide();
|
||||
names.show();
|
||||
return;
|
||||
}
|
||||
|
||||
const fuzzyOptions = {
|
||||
pre: "<b>",
|
||||
post: "</b>",
|
||||
extract: (el) => $(el).text(),
|
||||
};
|
||||
|
||||
const result = fuzzy.filter(
|
||||
value,
|
||||
names.find(".user").toArray(),
|
||||
fuzzyOptions
|
||||
);
|
||||
|
||||
names.hide();
|
||||
container.html(templates.user_filtered({matches: result})).show();
|
||||
|
||||
// Mark the first result as active for convenience
|
||||
container.find(".user").first().addClass("active");
|
||||
});
|
||||
|
||||
chat.on("mouseenter", ".userlist .user", function() {
|
||||
// Reset any potential selection, this is required in cas there is already a
|
||||
// nick previously selected by keyboard
|
||||
$(this).parent().find(".user.active").removeClass("active");
|
||||
|
||||
$(this).addClass("active");
|
||||
});
|
||||
|
||||
chat.on("mouseleave", ".userlist .user", function() {
|
||||
// Reset any potential selection
|
||||
$(this).parent().find(".user.active").removeClass("active");
|
||||
});
|
||||
|
||||
exports.handleKeybinds = function(input) {
|
||||
const trap = Mousetrap(input.get(0));
|
||||
|
||||
trap.bind(["up", "down"], (e, key) => {
|
||||
const userlists = input.closest(".userlist");
|
||||
let userlist;
|
||||
|
||||
// If input field has content, use the filtered list instead
|
||||
if (input.val().length) {
|
||||
userlist = userlists.find(".names-filtered");
|
||||
} else {
|
||||
userlist = userlists.find(".names-original");
|
||||
}
|
||||
|
||||
const users = userlist.find(".user");
|
||||
|
||||
if (users.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find which item in the array of users is currently selected, if any.
|
||||
// Returns -1 if none.
|
||||
const activeIndex = users.toArray()
|
||||
.findIndex((user) => user.classList.contains("active"));
|
||||
|
||||
// Now that we know which user is active, reset any selection
|
||||
userlist.find(".user.active").removeClass("active");
|
||||
|
||||
// Mark next/previous user as active.
|
||||
if (key === "down") {
|
||||
// If no users or last user were marked as active, mark the first one.
|
||||
users.eq((activeIndex + 1) % users.length).addClass("active");
|
||||
} else {
|
||||
// If no users or first user was marked as active, mark the last one.
|
||||
users.eq(Math.max(activeIndex, 0) - 1).addClass("active");
|
||||
}
|
||||
|
||||
// Adjust scroll when active item is outside of the visible area
|
||||
utils.scrollIntoViewNicely(userlist.find(".user.active")[0]);
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
// When pressing Enter, open the context menu (emit a click) on the active
|
||||
// user
|
||||
trap.bind("enter", () => {
|
||||
const user = input.closest(".userlist").find(".user.active");
|
||||
|
||||
if (user.length) {
|
||||
const clickEvent = new $.Event("click");
|
||||
const userOffset = user.offset();
|
||||
clickEvent.pageX = userOffset.left;
|
||||
clickEvent.pageY = userOffset.top + user.height();
|
||||
user.trigger(clickEvent);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
};
|
@ -3,44 +3,29 @@
|
||||
const $ = require("jquery");
|
||||
const escape = require("css.escape");
|
||||
const viewport = $("#viewport");
|
||||
const {vueApp} = require("./vue");
|
||||
|
||||
var serverHash = -1; // eslint-disable-line no-var
|
||||
var lastMessageId = -1; // eslint-disable-line no-var
|
||||
|
||||
module.exports = {
|
||||
// Same value as media query in CSS that forces sidebars to become overlays
|
||||
mobileViewportPixels: 768,
|
||||
inputCommands: {collapse, expand, join},
|
||||
findCurrentNetworkChan,
|
||||
serverHash,
|
||||
lastMessageId,
|
||||
confirmExit,
|
||||
scrollIntoViewNicely,
|
||||
hasRoleInChannel,
|
||||
move,
|
||||
closeChan,
|
||||
resetHeight,
|
||||
toggleNotificationMarkers,
|
||||
updateTitle,
|
||||
synchronizeNotifiedState,
|
||||
togglePasswordField,
|
||||
requestIdleCallback,
|
||||
togglePreviewMoreButtonsIfNeeded,
|
||||
};
|
||||
|
||||
function findCurrentNetworkChan(name) {
|
||||
name = name.toLowerCase();
|
||||
|
||||
return $(".network .chan.active")
|
||||
.parent(".network")
|
||||
.find(".chan")
|
||||
.filter(function() {
|
||||
return $(this).attr("aria-label").toLowerCase() === name;
|
||||
})
|
||||
.first();
|
||||
}
|
||||
|
||||
function resetHeight(element) {
|
||||
element.style.height = element.style.minHeight;
|
||||
return vueApp.activeChannel.network.channels.find((c) => c.name.toLowerCase() === name);
|
||||
}
|
||||
|
||||
// Given a channel element will determine if the lounge user or a given nick is one of the supplied roles.
|
||||
@ -49,10 +34,10 @@ function hasRoleInChannel(channel, roles, nick) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const channelID = channel.data("id");
|
||||
const channelID = channel.attr("data-id");
|
||||
const network = $("#sidebar .network").has(`.chan[data-id="${channelID}"]`);
|
||||
const target = nick || network.attr("data-nick");
|
||||
const user = channel.find(`.names-original .user[data-name="${escape(target)}"]`).first();
|
||||
const user = channel.find(`.names .user[data-name="${escape(target)}"]`).first();
|
||||
return user.parent().is("." + roles.join(", ."));
|
||||
}
|
||||
|
||||
@ -63,37 +48,33 @@ function scrollIntoViewNicely(el) {
|
||||
el.scrollIntoView({block: "center", inline: "nearest"});
|
||||
}
|
||||
|
||||
function collapse() {
|
||||
$(".chan.active .toggle-button.toggle-preview.opened").click();
|
||||
return true;
|
||||
}
|
||||
const favicon = $("#favicon");
|
||||
|
||||
function expand() {
|
||||
$(".chan.active .toggle-button.toggle-preview:not(.opened)").click();
|
||||
return true;
|
||||
}
|
||||
function synchronizeNotifiedState() {
|
||||
updateTitle();
|
||||
|
||||
function join(args) {
|
||||
const channel = args[0];
|
||||
let hasAnyHighlights = false;
|
||||
|
||||
if (channel) {
|
||||
const chan = findCurrentNetworkChan(channel);
|
||||
|
||||
if (chan.length) {
|
||||
chan.trigger("click");
|
||||
for (const network of vueApp.networks) {
|
||||
for (const chan of network.channels) {
|
||||
if (chan.highlight > 0) {
|
||||
hasAnyHighlights = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const favicon = $("#favicon");
|
||||
toggleNotificationMarkers(hasAnyHighlights);
|
||||
}
|
||||
|
||||
function toggleNotificationMarkers(newState) {
|
||||
// Toggles the favicon to red when there are unread notifications
|
||||
if (favicon.data("toggled") !== newState) {
|
||||
if (vueApp.isNotified !== newState) {
|
||||
vueApp.isNotified = newState;
|
||||
|
||||
const old = favicon.prop("href");
|
||||
favicon.prop("href", favicon.data("other"));
|
||||
favicon.data("other", old);
|
||||
favicon.data("toggled", newState);
|
||||
}
|
||||
|
||||
// Toggles a dot on the menu icon when there are unread notifications
|
||||
@ -101,18 +82,20 @@ function toggleNotificationMarkers(newState) {
|
||||
}
|
||||
|
||||
function updateTitle() {
|
||||
let title = $(document.body).data("app-name");
|
||||
const chanTitle = $("#sidebar").find(".chan.active").attr("aria-label");
|
||||
let title = vueApp.appName;
|
||||
|
||||
if (chanTitle && chanTitle.length > 0) {
|
||||
title = `${chanTitle} — ${title}`;
|
||||
if (vueApp.activeChannel) {
|
||||
title = `${vueApp.activeChannel.channel.name} — ${vueApp.activeChannel.network.name} — ${title}`;
|
||||
}
|
||||
|
||||
// add highlight count to title
|
||||
let alertEventCount = 0;
|
||||
$(".badge.highlight").each(function() {
|
||||
alertEventCount += parseInt($(this).attr("data-highlight"));
|
||||
});
|
||||
|
||||
for (const network of vueApp.networks) {
|
||||
for (const channel of network.channels) {
|
||||
alertEventCount += channel.highlight;
|
||||
}
|
||||
}
|
||||
|
||||
if (alertEventCount > 0) {
|
||||
title = `(${alertEventCount}) ${title}`;
|
||||
@ -175,7 +158,7 @@ function closeChan(chan) {
|
||||
}
|
||||
|
||||
socket.emit("input", {
|
||||
target: chan.data("id"),
|
||||
target: Number(chan.attr("data-id")),
|
||||
text: cmd,
|
||||
});
|
||||
chan.css({
|
||||
@ -194,9 +177,3 @@ function requestIdleCallback(callback, timeout) {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
// Force handling preview display
|
||||
function togglePreviewMoreButtonsIfNeeded() {
|
||||
$("#chat .chan.active .toggle-content.toggle-type-link.show")
|
||||
.trigger("showMoreIfNeeded");
|
||||
}
|
||||
|
89
client/js/vue.js
Normal file
89
client/js/vue.js
Normal file
@ -0,0 +1,89 @@
|
||||
"use strict";
|
||||
|
||||
const Vue = require("vue").default;
|
||||
const App = require("../components/App.vue").default;
|
||||
const roundBadgeNumber = require("./libs/handlebars/roundBadgeNumber");
|
||||
const localetime = require("./libs/handlebars/localetime");
|
||||
const friendlysize = require("./libs/handlebars/friendlysize");
|
||||
const colorClass = require("./libs/handlebars/colorClass");
|
||||
|
||||
Vue.filter("localetime", localetime);
|
||||
Vue.filter("friendlysize", friendlysize);
|
||||
Vue.filter("colorClass", colorClass);
|
||||
Vue.filter("roundBadgeNumber", roundBadgeNumber);
|
||||
|
||||
const vueApp = new Vue({
|
||||
el: "#viewport",
|
||||
data: {
|
||||
activeChannel: null,
|
||||
appName: document.title,
|
||||
currentUserVisibleError: null,
|
||||
initialized: false,
|
||||
isAutoCompleting: false,
|
||||
isConnected: false,
|
||||
isFileUploadEnabled: false,
|
||||
isNotified: false,
|
||||
networks: [],
|
||||
settings: {
|
||||
syncSettings: false,
|
||||
advanced: false,
|
||||
autocomplete: true,
|
||||
nickPostfix: "",
|
||||
coloredNicks: true,
|
||||
desktopNotifications: false,
|
||||
highlights: "",
|
||||
links: true,
|
||||
motd: true,
|
||||
notification: true,
|
||||
notifyAllMessages: false,
|
||||
showSeconds: false,
|
||||
statusMessages: "condensed",
|
||||
theme: document.getElementById("theme").dataset.serverTheme,
|
||||
media: true,
|
||||
userStyles: "",
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
Vue.nextTick(() => window.vueMounted());
|
||||
},
|
||||
render(createElement) {
|
||||
return createElement(App, {
|
||||
props: this,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
Vue.config.errorHandler = function(e) {
|
||||
console.error(e); // eslint-disable-line
|
||||
vueApp.currentUserVisibleError = `Vue error: ${e.message}. Please check devtools and report it in #thelounge`;
|
||||
};
|
||||
|
||||
function findChannel(id) {
|
||||
for (const network of vueApp.networks) {
|
||||
for (const channel of network.channels) {
|
||||
if (channel.id === id) {
|
||||
return {network, channel};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function initChannel(channel) {
|
||||
channel.pendingMessage = "";
|
||||
channel.inputHistoryPosition = 0;
|
||||
channel.inputHistory = [""];
|
||||
channel.historyLoading = false;
|
||||
channel.scrolledToBottom = true;
|
||||
|
||||
if (channel.type === "channel") {
|
||||
channel.usersOutdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
vueApp,
|
||||
findChannel,
|
||||
initChannel,
|
||||
};
|
@ -6,7 +6,7 @@
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
#windows .window {
|
||||
.window {
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 0 25px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
@ -17,7 +17,6 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#viewport #loading,
|
||||
#viewport #sign-in {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
@ -15,10 +15,12 @@
|
||||
color: white;
|
||||
}
|
||||
|
||||
#loading .logo,
|
||||
#windows .logo {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#loading .logo-inverted,
|
||||
#windows .logo-inverted {
|
||||
display: inline-block;
|
||||
}
|
||||
|
@ -1,6 +0,0 @@
|
||||
{{> ../user_name from}}
|
||||
<span class="text">{{{parse text users}}}</span>
|
||||
|
||||
{{#each previews}}
|
||||
<div class="preview" data-url="{{link}}"></div>
|
||||
{{/each}}
|
@ -1,7 +0,0 @@
|
||||
{{#if self}}
|
||||
{{{parse text}}}
|
||||
{{else}}
|
||||
{{> ../user_name from}}
|
||||
is away
|
||||
<i class="away-message">({{{parse text}}})</i>
|
||||
{{/if}}
|
@ -1,6 +0,0 @@
|
||||
{{#if self}}
|
||||
{{{parse text}}}
|
||||
{{else}}
|
||||
{{> ../user_name from}}
|
||||
is back
|
||||
{{/if}}
|
@ -1,18 +0,0 @@
|
||||
<table class="ban-list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="hostmask">Banned</th>
|
||||
<th class="banned_by">Banned By</th>
|
||||
<th class="banned_at">Banned At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each bans}}
|
||||
<tr>
|
||||
<td class="hostmask">{{hostmask}}</td>
|
||||
<td class="banned_by">{{{parse banned_by}}}</td>
|
||||
<td class="banned_at">{{{localetime banned_at}}}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
@ -1,18 +0,0 @@
|
||||
<table class="channel-list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="channel">Channel</th>
|
||||
<th class="users">Users</th>
|
||||
<th class="topic">Topic</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each channels}}
|
||||
<tr>
|
||||
<td class="channel">{{{parse channel}}}</td>
|
||||
<td class="users">{{num_users}}</td>
|
||||
<td class="topic">{{{parse topic}}}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
@ -1,4 +0,0 @@
|
||||
{{> ../user_name nick=from.nick mode=from.mode}}
|
||||
has changed
|
||||
{{#if new_ident}}username to <b>{{new_ident}}</b>{{#if new_host}}, and{{/if}}{{/if}}
|
||||
{{#if new_host}}hostname to <i class="hostmask">{{new_host}}</i>{{/if}}
|
@ -1,2 +0,0 @@
|
||||
{{> ../user_name from}}
|
||||
<span class="ctcp-message">{{{parse ctcpMessage}}}</span>
|
@ -1,3 +0,0 @@
|
||||
{{> ../user_name from}}
|
||||
sent a <abbr title="Client-to-client protocol">CTCP</abbr> request:
|
||||
<span class="ctcp-message">{{{parse ctcpMessage}}}</span>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user