Merge pull request #2647 from thelounge/vue

Port sidebar, chat, user list, state to Vue
This commit is contained in:
Pavel Djundik 2019-02-16 20:01:01 +02:00 committed by GitHub
commit ae72d5828c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
154 changed files with 3942 additions and 2999 deletions

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@ -0,0 +1,23 @@
<template>
<span class="content">
<Username :user="message.from" />&#32;
<span class="ctcp-message"><ParsedMessage :text="message.ctcpMessage" /></span>
</span>
</template>
<script>
import ParsedMessage from "../ParsedMessage.vue";
import Username from "../Username.vue";
export default {
name: "MessageTypeCTCP",
components: {
ParsedMessage,
Username,
},
props: {
network: Object,
message: Object,
},
};
</script>

View File

@ -0,0 +1,24 @@
<template>
<span class="content">
<Username :user="message.from" />
sent a <abbr title="Client-to-client protocol">CTCP</abbr> request:
<span class="ctcp-message"><ParsedMessage :text="message.ctcpMessage" /></span>
</span>
</template>
<script>
import ParsedMessage from "../ParsedMessage.vue";
import Username from "../Username.vue";
export default {
name: "MessageTypeRequestCTCP",
components: {
ParsedMessage,
Username,
},
props: {
network: Object,
message: Object,
},
};
</script>

View File

@ -0,0 +1,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;
}, {});

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

View File

@ -0,0 +1,22 @@
<template>
<span class="content">
<Username :user="message.from" />
<i class="hostmask"> ({{ message.hostmask }})</i>
has joined the channel
</span>
</template>
<script>
import Username from "../Username.vue";
export default {
name: "MessageTypeJoin",
components: {
Username,
},
props: {
network: Object,
message: Object,
},
};
</script>

View File

@ -0,0 +1,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>

View File

@ -0,0 +1,24 @@
<template>
<span class="content">
<Username :user="message.from" />
sets mode
<ParsedMessage :message="message" />
</span>
</template>
<script>
import ParsedMessage from "../ParsedMessage.vue";
import Username from "../Username.vue";
export default {
name: "MessageTypeMode",
components: {
ParsedMessage,
Username,
},
props: {
network: Object,
message: Object,
},
};
</script>

View File

@ -0,0 +1,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>

View File

@ -0,0 +1,22 @@
<template>
<span class="content">
<Username :user="message.from" />
is now known as
<Username :user="{nick: message.new_nick, mode: message.from.mode}" />
</span>
</template>
<script>
import Username from "../Username.vue";
export default {
name: "MessageTypeNick",
components: {
Username,
},
props: {
network: Object,
message: Object,
},
};
</script>

View File

@ -0,0 +1,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>

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

View File

@ -0,0 +1,28 @@
<template>
<span class="content">
<template v-if="message.from && message.from.nick"><Username :user="message.from" /> has changed the topic to: </template>
<template v-else>The topic is: </template>
<span
v-if="message.text"
class="new-topic"><ParsedMessage
:network="network"
:message="message" /></span>
</span>
</template>
<script>
import ParsedMessage from "../ParsedMessage.vue";
import Username from "../Username.vue";
export default {
name: "MessageTypeTopic",
components: {
ParsedMessage,
Username,
},
props: {
network: Object,
message: Object,
},
};
</script>

View File

@ -0,0 +1,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>

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,8 @@
"use strict";
const $ = require("jquery");
exports.input = function() {
$(".chan.active .toggle-button.toggle-preview.opened").click();
return true;
};

View File

@ -0,0 +1,8 @@
"use strict";
const $ = require("jquery");
exports.input = function() {
$(".chan.active .toggle-button.toggle-preview:not(.opened)").click();
return true;
};

View File

@ -0,0 +1,21 @@
"use strict";
// Taken from views/index.js
// This creates a version of `require()` in the context of the current
// directory, so we iterate over its content, which is a map statically built by
// Webpack.
// Second argument says it's recursive, third makes sure we only load javascript.
const commands = require.context("./", true, /\.js$/);
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;
}, {});

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

View File

@ -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());
}

View File

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

View File

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

View File

@ -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) {

View File

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

View File

@ -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",
});
};

View File

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

View File

@ -1,15 +0,0 @@
"use strict";
const modes = {
"~": "owner",
"&": "admin",
"!": "admin",
"@": "op",
"%": "half-op",
"+": "voice",
"": "normal",
};
module.exports = function(mode) {
return modes[mode];
};

View File

@ -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");
}
};

View File

@ -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("");
});
};

View File

@ -1,7 +0,0 @@
"use strict";
const escape = require("css.escape");
module.exports = function(orig) {
return escape(orig.toLowerCase());
};

View File

@ -1,5 +0,0 @@
"use strict";
module.exports = function tojson(context) {
return JSON.stringify(context);
};

View File

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

View File

@ -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);

View File

@ -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.";

View File

@ -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();
});
};

View File

@ -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);

View File

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

View File

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

View File

@ -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", {

View File

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

View File

@ -7,6 +7,7 @@ require("./join");
require("./more");
require("./msg");
require("./msg_preview");
require("./msg_special");
require("./names");
require("./network");
require("./nick");

View File

@ -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) {

View File

@ -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");
});
});

View File

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

View File

@ -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();
}
}

View File

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

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

View File

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

View File

@ -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();
});

View File

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

View File

@ -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();
});

View File

@ -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();
});

View File

@ -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");
}
});

View File

@ -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
}

View File

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

View File

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

View File

@ -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;

View File

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

View File

@ -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);

View File

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

View File

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

View File

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

View File

@ -15,10 +15,12 @@
color: white;
}
#loading .logo,
#windows .logo {
display: none;
}
#loading .logo-inverted,
#windows .logo-inverted {
display: inline-block;
}

View File

@ -1,6 +0,0 @@
{{> ../user_name from}}
<span class="text">{{{parse text users}}}</span>
{{#each previews}}
<div class="preview" data-url="{{link}}"></div>
{{/each}}

View File

@ -1,7 +0,0 @@
{{#if self}}
{{{parse text}}}
{{else}}
{{> ../user_name from}}
is away
<i class="away-message">({{{parse text}}})</i>
{{/if}}

View File

@ -1,6 +0,0 @@
{{#if self}}
{{{parse text}}}
{{else}}
{{> ../user_name from}}
is back
{{/if}}

View File

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

View File

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

View File

@ -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}}

View File

@ -1,2 +0,0 @@
{{> ../user_name from}}
<span class="ctcp-message">{{{parse ctcpMessage}}}</span>

View File

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