Channel list rendering with Vue

Co-Authored-By: Tim Miller-Williams <timmw@users.noreply.github.com>
This commit is contained in:
Pavel Djundik 2018-07-06 21:15:15 +03:00 committed by Pavel Djundik
parent 18bca3bce1
commit 7e332b817d
30 changed files with 707 additions and 285 deletions

View file

@ -85,8 +85,15 @@ 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
extends: eslint:recommended
plugins:
- vue
extends:
- eslint:recommended
- plugin:vue/recommended

130
client/components/App.vue Normal file
View file

@ -0,0 +1,130 @@
<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>
<Network
: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">
<div
id="chat-container"
class="window">
<div id="chat"/>
<div id="connection-error"/>
<form
id="form"
method="post"
action="">
<span id="nick"/>
<textarea
id="input"
class="mousetrap"/>
<span
id="submit-tooltip"
class="tooltipped tooltipped-w tooltipped-no-touch"
aria-label="Send message">
<button
id="submit"
type="submit"
aria-label="Send message"/>
</span>
</form>
</div>
<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>
import Network from "./Network.vue";
export default {
name: "App",
components: {
Network,
},
props: {
activeChannel: Object,
networks: Array,
},
methods: {
isPublic: () => document.body.classList.contains("public"),
},
};
</script>

View file

@ -0,0 +1,90 @@
<template>
<div
:key="channel.id"
:class="[ channel.type, { active: activeChannel && channel.id === activeChannel.channel.id } ]"
:aria-label="channel.name"
:title="channel.name"
:data-id="channel.id"
:data-target="'#chan-' + channel.id"
:aria-controls="'#chan-' + channel.id"
:aria-selected="activeChannel && channel.id === activeChannel.channel.id"
class="chan"
role="tab"
>
<template v-if="channel.type === 'lobby'">
<button
:aria-controls="'network-' + network.uuid"
class="collapse-network"
aria-label="Collapse"
aria-expanded="true">
<span class="collapse-network-icon"/>
</button>
<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
class="add-channel-tooltip tooltipped tooltipped-w tooltipped-no-touch"
aria-label="Join a channel…"
data-alt-label="Cancel">
<button
:aria-controls="'join-channel-' + channel.id"
class="add-channel"
aria-label="Join a channel…"/>
</span>
</template>
<template v-else>
<span
:title="channel.name"
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>
</template>
</div>
</template>
<script>
export default {
name: "Channel",
props: {
activeChannel: Object,
network: Object,
channel: Object,
},
};
</script>

View file

@ -0,0 +1,42 @@
<template>
<form
:id="'join-channel-' + channel.id"
class="join-form"
method="post"
action=""
autocomplete="off"
>
<input
type="text"
class="input"
name="channel"
placeholder="Channel"
pattern="[^\s]+"
maxlength="200"
title="The channel name may not contain spaces"
required
>
<input
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>
export default {
name: "JoinChannel",
props: {
channel: Object,
},
};
</script>

View file

@ -0,0 +1,115 @@
<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' }"
class="networks"
@change="onNetworkSort"
@start="onDragStart"
@end="onDragEnd"
>
<div
v-for="network in networks"
:key="network.uuid"
:class="{ 'not-connected': !network.status.connected, 'not-secure': !network.status.secure }"
:id="'network-' + network.uuid"
:data-uuid="network.uuid"
:data-nick="network.nick"
class="network"
role="region"
>
<Channel
:channel="network.channels[0]"
:network="network"
:active-channel="activeChannel"
/>
<JoinChannel :channel="network.channels[0]"/>
<Draggable
:options="{ draggable: '.chan', ghostClass: 'chan-placeholder' }"
: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 JoinChannel from "./JoinChannel.vue";
import Channel from "./Channel.vue";
// TODO: ignoreSortSync should be removed
import {findChannel} from "../js/vue";
import socket from "../js/socket";
// import options from "../js/options";
export default {
name: "Network",
components: {
JoinChannel,
Channel,
Draggable,
},
props: {
activeChannel: Object,
networks: Array,
},
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),
});
// options.settings.ignoreSortSync = true;
},
onChannelSort(e) {
if (!e.moved) {
return;
}
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),
});
// options.settings.ignoreSortSync = true;
},
},
};
</script>

View file

@ -603,10 +603,6 @@ background on hover (unless active) */
padding-top: 5px;
}
#sidebar .networks:empty {
display: none;
}
#sidebar .network,
#sidebar .network-placeholder {
position: relative;
@ -631,7 +627,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 +775,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,6 +886,7 @@ background on hover (unless active) */
line-height: 1.5;
}
#loading,
#windows .window {
background: var(--window-bg-color);
display: none;
@ -1605,7 +1596,7 @@ 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;
display: flex;
@ -1646,7 +1637,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
flex-grow: 0;
}
#windows .logo-inverted {
#loading .logo-inverted {
display: none; /* In dark themes, inverted logo must be used instead */
}
@ -2410,10 +2401,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;

View file

@ -104,6 +104,7 @@
<div id="changelog" class="window" aria-label="Changelog"></div>
</article>
</div>
<div id="viewport"></div>
<div id="context-menu-container"></div>
<div id="image-viewer"></div>

View file

@ -8,7 +8,6 @@ const emojiMap = require("./libs/simplemap.json");
const options = require("./options");
const constants = require("./constants");
const input = $("#input");
let textcomplete;
let enabled = false;
@ -16,6 +15,7 @@ module.exports = {
enable: enableAutocomplete,
disable() {
if (enabled) {
const input = $("#input");
input.off("input.tabcomplete");
Mousetrap(input.get(0)).unbind("tab", "keydown");
textcomplete.destroy();
@ -74,7 +74,7 @@ const nicksStrategy = {
}
// 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 + " ";
}
@ -179,6 +179,7 @@ function enableAutocomplete() {
let tabCount = 0;
let lastMatch = "";
let currentMatches = [];
const input = $("#input");
input.on("input.tabcomplete", () => {
tabCount = 0;

View file

@ -1,7 +1,6 @@
"use strict";
// vendor libraries
require("jquery-ui/ui/widgets/sortable");
const $ = require("jquery");
const moment = require("moment");
@ -19,12 +18,12 @@ require("./keybinds");
require("./clipboard");
const contextMenuFactory = require("./contextMenuFactory");
const {vueApp, findChannel} = require("./vue");
$(function() {
const sidebar = $("#sidebar, #footer");
const chat = $("#chat");
$(document.body).data("app-name", document.title);
const viewport = $("#viewport");
function storeSidebarVisibility(name, state) {
@ -176,16 +175,14 @@ $(function() {
self.data("id")
);
sidebar.find(".active")
.removeClass("active")
.attr("aria-selected", false);
const channel = findChannel(self.data("id"));
self.addClass("active")
.attr("aria-selected", true)
.find(".badge")
.attr("data-highlight", 0)
.removeClass("highlight")
.empty();
vueApp.activeChannel = channel;
if (channel) {
channel.channel.highlight = 0;
channel.channel.unread = 0;
}
if (sidebar.find(".highlight").length === 0) {
utils.toggleNotificationMarkers(false);

View file

@ -5,7 +5,6 @@ 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");
@ -215,13 +214,6 @@ function renderChannelUsers(data) {
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);
@ -287,7 +279,6 @@ function renderNetworks(data, singleNetwork) {
}
utils.confirmExit();
sorting();
if (sidebar.find(".highlight").length) {
utils.toggleNotificationMarkers(true);

View file

@ -1,9 +1,5 @@
"use strict";
const viewport = document.getElementById("viewport");
const menu = document.getElementById("sidebar");
const sidebarOverlay = document.getElementById("sidebar-overlay");
let touchStartPos = null;
let touchCurPos = null;
let touchStartTime = 0;
@ -14,12 +10,16 @@ let menuIsAbsolute = false;
class SlideoutMenu {
static enable() {
this.viewport = document.getElementById("viewport");
this.menu = document.getElementById("sidebar");
this.sidebarOverlay = document.getElementById("sidebar-overlay");
document.body.addEventListener("touchstart", onTouchStart, {passive: true});
}
static toggle(state) {
menuIsOpen = state;
viewport.classList.toggle("menu-open", state);
this.viewport.classList.toggle("menu-open", state);
}
static isOpen() {
@ -35,7 +35,7 @@ function onTouchStart(e) {
return;
}
const styles = window.getComputedStyle(menu);
const styles = window.getComputedStyle(this.menu);
menuWidth = parseFloat(styles.width);
menuIsAbsolute = styles.position === "absolute";
@ -65,7 +65,7 @@ function onTouchMove(e) {
const devicePixelRatio = window.devicePixelRatio || 2;
if (Math.abs(distX) > devicePixelRatio) {
viewport.classList.toggle("menu-dragging", true);
this.viewport.classList.toggle("menu-dragging", true);
menuIsMoving = true;
}
}
@ -85,8 +85,8 @@ function onTouchMove(e) {
distX = 0;
}
menu.style.transform = "translate3d(" + distX + "px, 0, 0)";
sidebarOverlay.style.opacity = distX / menuWidth;
this.menu.style.transform = "translate3d(" + distX + "px, 0, 0)";
this.sidebarOverlay.style.opacity = distX / menuWidth;
}
function onTouchEnd() {
@ -99,9 +99,9 @@ function onTouchEnd() {
document.body.removeEventListener("touchmove", onTouchMove);
document.body.removeEventListener("touchend", onTouchEnd);
viewport.classList.toggle("menu-dragging", false);
menu.style.transform = null;
sidebarOverlay.style.opacity = null;
this.viewport.classList.toggle("menu-dragging", false);
this.menu.style.transform = null;
this.sidebarOverlay.style.opacity = null;
touchStartPos = null;
touchCurPos = null;

View file

@ -21,6 +21,7 @@ socket.on("auth", function(data) {
if (data.serverHash > -1) {
utils.serverHash = data.serverHash;
$("#loading").remove();
login.html(templates.windows.sign_in());
utils.togglePasswordField("#sign-in .reveal-password");

View file

@ -9,6 +9,7 @@ const slideoutMenu = require("../slideout");
const sidebar = $("#sidebar");
const storage = require("../localStorage");
const utils = require("../utils");
const {Vue, vueApp} = require("../vue");
socket.on("init", function(data) {
$("#loading-page-message, #connection-error").text("Rendering…");
@ -18,13 +19,12 @@ socket.on("init", function(data) {
if (lastMessageId > -1) {
previousActive = sidebar.find(".active").data("id");
sidebar.find(".networks").empty();
}
if (data.networks.length === 0) {
sidebar.find(".empty").show();
} else {
render.renderNetworks(data);
vueApp.networks = data.networks;
if (data.networks.length > 0) {
Vue.nextTick(() => render.renderNetworks(data));
}
$("#connection-error").removeClass("shown");
@ -66,7 +66,7 @@ socket.on("init", function(data) {
}
}
openCorrectChannel(previousActive, data.active);
Vue.nextTick(() => openCorrectChannel(previousActive, data.active));
});
function openCorrectChannel(clientActive, serverActive) {

View file

@ -6,32 +6,31 @@ const render = require("../render");
const chat = $("#chat");
const templates = require("../../views");
const sidebar = $("#sidebar");
const {Vue, vueApp} = 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);
vueApp.networks.find((n) => n.uuid === data.network)
.channels.splice(data.index || -1, 0, data.chan);
chat.append(
templates.chat({
channels: [data.chan],
})
);
render.renderChannel(data.chan);
Vue.nextTick(() => render.renderChannel(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");
Vue.nextTick(() => {
sidebar.find(".chan")
.sort(function(a, b) {
return $(a).data("id") - $(b).data("id");
})
.last()
.trigger("click");
});
});

View file

@ -10,6 +10,7 @@ const cleanIrcMessage = require("../libs/handlebars/ircmessageparser/cleanIrcMes
const webpush = require("../webpush");
const chat = $("#chat");
const sidebar = $("#sidebar");
const {vueApp, findChannel} = require("../vue");
let pop;
@ -30,26 +31,31 @@ socket.on("msg", function(data) {
function processReceivedMessage(data) {
let targetId = data.chan;
let target = "#chan-" + targetId;
let channel = chat.find(target);
let sidebarTarget = sidebar.find("[data-target='" + target + "']");
let channelContainer = chat.find(target);
let channel = findChannel(data.chan);
// Clear unread/highlight counter if self-message
if (data.msg.self) {
channel.channel.highlight = 0;
channel.channel.unread = 0;
utils.updateTitle();
}
// 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 === channel.network) {
channel = vueApp.activeChannel;
// 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");
targetId = data.chan = vueApp.activeChannel.channel.id;
target = "#chan-" + targetId;
channel = chat.find(target);
sidebarTarget = sidebar.find("[data-target='" + target + "']");
}
target = "#chan-" + targetId;
channelContainer = chat.find(target);
}
const scrollContainer = channel.find(".chat");
const container = channel.find(".messages");
const scrollContainer = channelContainer.find(".chat");
const container = channelContainer.find(".messages");
const activeChannelId = chat.find(".chan.active").data("id");
if (data.msg.type === "channel_list" || data.msg.type === "ban_list" || data.msg.type === "ignore_list") {
@ -60,7 +66,7 @@ function processReceivedMessage(data) {
render.appendMessage(
container,
targetId,
channel.data("type"),
channelContainer.data("type"),
data.msg
);
@ -68,7 +74,7 @@ function processReceivedMessage(data) {
scrollContainer.trigger("keepToBottom");
}
notifyMessage(targetId, channel, data);
notifyMessage(targetId, channelContainer, data);
let shouldMoveMarker = data.msg.self;
@ -95,16 +101,6 @@ function processReceivedMessage(data) {
.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();
}
let messageLimit = 0;
if (activeChannelId !== targetId) {
@ -116,11 +112,11 @@ function processReceivedMessage(data) {
}
if (messageLimit > 0) {
render.trimMessageInChannel(channel, messageLimit);
render.trimMessageInChannel(channelContainer, messageLimit);
}
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") && channelContainer.hasClass("channel")) {
const nicks = channelContainer.find(".userlist").data("nicks");
if (nicks) {
const find = nicks.indexOf(data.msg.from.nick);

View file

@ -6,13 +6,18 @@ const render = require("../render");
const templates = require("../../views");
const sidebar = $("#sidebar");
const utils = require("../utils");
const {Vue, vueApp} = require("../vue");
socket.on("network", function(data) {
render.renderNetworks(data, true);
vueApp.networks.push(data.networks[0]);
sidebar.find(".chan")
.last()
.trigger("click");
Vue.nextTick(() => {
render.renderNetworks(data, true);
sidebar.find(".chan")
.last()
.trigger("click");
});
$("#connect")
.find(".btn")
@ -20,14 +25,13 @@ socket.on("network", function(data) {
});
socket.on("network_changed", function(data) {
sidebar.find(`.network[data-uuid="${data.network}"]`).data("options", data.serverOptions);
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);
network.status.connected = data.connected;
network.status.secure = data.secure;
});
socket.on("network:info", function(data) {

View file

@ -3,6 +3,7 @@
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 +11,25 @@ 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);
if (channel) {
channel.channel.highlight = 0;
channel.channel.unread = 0;
}
utils.updateTitle();
// Move unread marker to the bottom
channel
const channelContainer = $("#chat #chan-" + id);
channelContainer
.find(".unread-marker")
.data("unread-id", 0)
.appendTo(channel.find(".messages"));
.appendTo(channelContainer.find(".messages"));
});

View file

@ -2,19 +2,19 @@
const $ = require("jquery");
const socket = require("../socket");
const sidebar = $("#sidebar");
const {vueApp} = 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 network = vueApp.networks.find((n) => n.uuid === data.network);
network.channels.splice(network.channels.findIndex((c) => c.id === data.chan), 1);
});

View file

@ -4,8 +4,11 @@ const $ = require("jquery");
const chat = $("#chat");
const socket = require("../socket");
const sidebar = $("#sidebar");
const {Vue, vueApp} = require("../vue");
socket.on("quit", function(data) {
vueApp.networks.splice(vueApp.networks.findIndex((n) => n.uuid === data.network), 1);
const id = data.network;
const network = sidebar.find(`.network[data-uuid="${id}"]`);
@ -14,18 +17,16 @@ socket.on("quit", function(data) {
chat.find($(this).attr("data-target")).remove();
});
network.remove();
Vue.nextTick(() => {
const chan = sidebar.find(".chan");
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");
}
if (chan.length === 0) {
// Open the connect window
$("#footer .connect").trigger("click", {
pushState: false,
});
} else {
chan.eq(0).trigger("click");
}
});
});

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

@ -3,6 +3,7 @@
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
@ -101,18 +102,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}`;

39
client/js/vue.js Normal file
View file

@ -0,0 +1,39 @@
"use strict";
const Vue = require("vue").default;
const App = require("../components/App.vue").default;
const roundBadgeNumber = require("./libs/handlebars/roundBadgeNumber");
Vue.filter("roundBadgeNumber", roundBadgeNumber);
const vueApp = new Vue({
el: "#viewport",
data: {
appName: document.title,
activeChannel: null,
networks: [],
},
render(createElement) {
return createElement(App, {
props: this,
});
},
});
function findChannel(id) {
for (const network of vueApp.networks) {
for (const channel of network.channels) {
if (channel.id === id) {
return {network, channel};
}
}
}
return null;
}
module.exports = {
Vue,
vueApp,
findChannel,
};

View file

@ -1,45 +0,0 @@
{{#each channels}}
<div
class="chan {{type}} chan-{{slugify name}}"
data-id="{{id}}"
data-target="#chan-{{id}}"
role="tab"
aria-label="{{name}}"
aria-controls="chan-{{id}}"
aria-selected="false"
>
{{#equal type "lobby"}}
<button class="collapse-network" aria-label="Collapse" aria-controls="network-{{../uuid}}" aria-expanded="true">
<span class="collapse-network-icon"></span>
</button>
<div class="lobby-wrap">
<span class="name" title="{{name}}">{{name}}</span>
<span class="not-secure-tooltip tooltipped tooltipped-w" aria-label="Insecure connection">
<span class="not-secure-icon"></span>
</span>
<span class="not-connected-tooltip tooltipped tooltipped-w" aria-label="Disconnected">
<span class="not-connected-icon"></span>
</span>
<span class="badge{{#if highlight}} highlight{{/if}}" data-highlight="{{highlight}}">{{#if unread}}{{roundBadgeNumber unread}}{{/if}}</span>
</div>
<span class="add-channel-tooltip tooltipped tooltipped-w tooltipped-no-touch" aria-label="Join a channel…" data-alt-label="Cancel">
<button class="add-channel" aria-label="Join a channel…" aria-controls="join-channel-{{id}}"></button>
</span>
{{else}}
<span class="name" title="{{name}}">{{name}}</span>
<span class="badge{{#if highlight}} highlight{{/if}}" data-highlight="{{highlight}}">{{#if unread}}{{roundBadgeNumber unread}}{{/if}}</span>
{{#equal type "channel"}}
<span class="close-tooltip tooltipped tooltipped-w" aria-label="Leave">
<button class="close" aria-label="Leave"></button>
</span>
{{else}}
<span class="close-tooltip tooltipped tooltipped-w" aria-label="Close">
<button class="close" aria-label="Close"></button>
</span>
{{/equal}}
{{/equal}}
</div>
{{#equal type "lobby"}}
{{> join_channel}}
{{/equal}}
{{/each}}

View file

@ -1,5 +0,0 @@
<form id="join-channel-{{id}}" class="join-form" method="post" action="" autocomplete="off">
<input type="text" class="input" name="channel" placeholder="Channel" pattern="[^\s]+" maxlength="200" title="The channel name may not contain spaces" required>
<input 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>

View file

@ -1,12 +0,0 @@
{{#each networks}}
<section
class="network name-{{slugify name}} {{#if serverOptions.NETWORK}}network-{{slugify serverOptions.NETWORK}}{{/if}} {{#unless status.connected}}not-connected{{/unless}} {{#unless status.secure}}not-secure{{/unless}}"
id="network-{{uuid}}"
data-uuid="{{uuid}}"
data-nick="{{nick}}"
data-options="{{tojson serverOptions}}"
role="region"
>
{{> chan}}
</section>
{{/each}}

View file

@ -16,7 +16,7 @@
"coverage": "run-s test:{client,server} && nyc --nycrc-path=test/.nycrc-report report",
"dev": "run-p watch start",
"lint:css": "stylelint --color \"client/**/*.css\"",
"lint:js": "eslint . --report-unused-disable-directives --color",
"lint:js": "eslint . --ext .js,.vue --report-unused-disable-directives --color",
"start": "node index start",
"test": "run-p --aggregate-output --continue-on-error lint:* test:{client,server}",
"test:browser": "webpack-dev-server --config=webpack.config-browser.js",
@ -80,6 +80,7 @@
"css.escape": "1.5.1",
"emoji-regex": "7.0.3",
"eslint": "5.13.0",
"eslint-plugin-vue": "4.5.0",
"fuzzy": "0.1.3",
"graphql-request": "1.8.2",
"handlebars": "4.1.0",
@ -105,6 +106,10 @@
"stylelint-config-standard": "18.2.0",
"textcomplete": "0.17.1",
"undate": "0.3.0",
"vue": "2.5.16",
"vue-loader": "15.2.4",
"vue-template-compiler": "2.5.16",
"vuedraggable": "2.16.0",
"webpack": "4.29.3",
"webpack-cli": "3.2.3",
"webpack-dev-server": "3.1.14"

View file

@ -35,6 +35,7 @@ exports.input = function(network, chan, cmd, args) {
network.channels = _.without(network.channels, target);
target.destroy();
this.emit("part", {
network: network.uuid,
chan: target.id,
});
this.save();

View file

@ -29,6 +29,7 @@ module.exports = function(irc, network) {
chan.destroy();
client.save();
client.emit("part", {
network: network.uuid,
chan: chan.id,
});
} else {

View file

@ -4,6 +4,7 @@ const webpack = require("webpack");
const path = require("path");
const CopyPlugin = require("copy-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const VueLoaderPlugin = require("vue-loader/lib/plugin");
const config = {
mode: process.env.NODE_ENV === "production" ? "production" : "development",
@ -19,6 +20,12 @@ const config = {
},
module: {
rules: [
{
test: /\.vue$/,
use: {
loader: "vue-loader",
},
},
{
test: /\.css$/,
include: [
@ -98,6 +105,7 @@ const config = {
},
plugins: [
new MiniCssExtractPlugin(),
new VueLoaderPlugin(),
new CopyPlugin([
{
from: "./node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.woff*",

147
yarn.lock
View file

@ -594,6 +594,20 @@
"@types/unist" "*"
"@types/vfile-message" "*"
"@vue/component-compiler-utils@^1.2.1":
version "1.3.1"
resolved "https://registry.yarnpkg.com/@vue/component-compiler-utils/-/component-compiler-utils-1.3.1.tgz#686f0b913d59590ae327b2a1cb4b6d9b931bbe0e"
dependencies:
consolidate "^0.15.1"
hash-sum "^1.0.2"
lru-cache "^4.1.2"
merge-source-map "^1.1.0"
postcss "^6.0.20"
postcss-selector-parser "^3.1.1"
prettier "^1.13.0"
source-map "^0.5.6"
vue-template-es2015-compiler "^1.6.0"
"@webassemblyjs/ast@1.7.11":
version "1.7.11"
resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.7.11.tgz#b988582cafbb2b095e8b556526f30c90d057cace"
@ -746,9 +760,23 @@ acorn-dynamic-import@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/acorn-dynamic-import/-/acorn-dynamic-import-4.0.0.tgz#482210140582a36b83c3e342e1cfebcaa9240948"
acorn-jsx@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-3.0.1.tgz#afdf9488fb1ecefc8348f6fb22f464e32a58b36b"
dependencies:
acorn "^3.0.4"
acorn-jsx@^5.0.0:
version "5.0.1"
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.0.1.tgz#32a064fd925429216a09b141102bfdd185fae40e"
version "5.0.0"
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.0.0.tgz#958584ddb60990c02c97c1bd9d521fce433bb101"
acorn@^3.0.4:
version "3.3.0"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-3.3.0.tgz#45e37fb39e8da3f25baee3ff5369e2bb5f22017a"
acorn@^5.5.0:
version "5.7.1"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.7.1.tgz#f095829297706a7c9776958c0afc8930a9b9d9d8"
acorn@^6.0.2, acorn@^6.0.5:
version "6.0.5"
@ -1198,7 +1226,11 @@ blob@0.0.5:
version "0.0.5"
resolved "https://registry.yarnpkg.com/blob/-/blob-0.0.5.tgz#d680eeef25f8cd91ad533f5b01eed48e64caf683"
bluebird@^3.5.1, bluebird@^3.5.3:
bluebird@^3.1.1, bluebird@^3.5.1:
version "3.5.1"
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.1.tgz#d9551f9de98f1fcda1e683d17ee91a0602ee2eb9"
bluebird@^3.5.3:
version "3.5.3"
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.3.tgz#7d01c6f9616c9a51ab0f8c549a79dfe6ec33efa7"
@ -1845,6 +1877,12 @@ console-control-strings@^1.0.0, console-control-strings@~1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e"
consolidate@^0.15.1:
version "0.15.1"
resolved "https://registry.yarnpkg.com/consolidate/-/consolidate-0.15.1.tgz#21ab043235c71a07d45d9aad98593b0dba56bab7"
dependencies:
bluebird "^3.1.1"
constants-browserify@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75"
@ -2125,6 +2163,10 @@ date-now@^0.1.4:
version "0.1.4"
resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b"
de-indent@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/de-indent/-/de-indent-1.0.2.tgz#b2038e846dc33baa5796128d0804b455b8c1e21d"
debug@2.6.9, debug@^2.1.2, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8:
version "2.6.9"
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
@ -2544,6 +2586,19 @@ escape-string-regexp@1.0.5, escape-string-regexp@^1.0.2, escape-string-regexp@^1
version "1.0.5"
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
eslint-plugin-vue@4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-vue/-/eslint-plugin-vue-4.5.0.tgz#09d6597f4849e31a3846c2c395fccf17685b69c3"
dependencies:
vue-eslint-parser "^2.0.3"
eslint-scope@^3.7.1:
version "3.7.1"
resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-3.7.1.tgz#3d63c3edfda02e06e01a452ad88caacc7cdcb6e8"
dependencies:
esrecurse "^4.1.0"
estraverse "^4.1.1"
eslint-scope@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-4.0.0.tgz#50bf3071e9338bcdc43331794a0cb533f0136172"
@ -2600,6 +2655,13 @@ eslint@5.13.0:
table "^5.0.2"
text-table "^0.2.0"
espree@^3.5.2:
version "3.5.4"
resolved "https://registry.yarnpkg.com/espree/-/espree-3.5.4.tgz#b0f447187c8a8bed944b815a660bddf5deb5d1a7"
dependencies:
acorn "^5.5.0"
acorn-jsx "^3.0.0"
espree@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/espree/-/espree-5.0.0.tgz#fc7f984b62b36a0f543b13fb9cd7b9f4a7f5b65c"
@ -2616,7 +2678,7 @@ esprima@^4.0.0:
version "4.0.1"
resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71"
esquery@^1.0.1:
esquery@^1.0.0, esquery@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.0.1.tgz#406c51658b1f5991a5f9b62b1dc25b00e3e5c708"
dependencies:
@ -3385,6 +3447,10 @@ hash-base@^3.0.0:
inherits "^2.0.1"
safe-buffer "^5.0.1"
hash-sum@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/hash-sum/-/hash-sum-1.0.2.tgz#33b40777754c6432573c120cc3808bbd10d47f04"
hash.js@^1.0.0, hash.js@^1.0.3:
version "1.1.7"
resolved "https://registry.yarnpkg.com/hash.js/-/hash.js-1.1.7.tgz#0babca538e8d4ee4a0f8988d68866537a003cf42"
@ -3398,7 +3464,7 @@ hasha@^3.0.0:
dependencies:
is-stream "^1.0.1"
he@1.1.1:
he@1.1.1, he@^1.1.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/he/-/he-1.1.1.tgz#93410fd21b009735151f8868c2f271f3427e23fd"
@ -4434,9 +4500,9 @@ lowercase-keys@^1.0.0, lowercase-keys@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f"
lru-cache@^4.0.1, lru-cache@^4.1.1:
version "4.1.5"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd"
lru-cache@^4.0.1, lru-cache@^4.1.1, lru-cache@^4.1.2:
version "4.1.3"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.3.tgz#a1175cf3496dfc8436c156c334b4955992bce69c"
dependencies:
pseudomap "^1.0.2"
yallist "^2.1.2"
@ -5736,7 +5802,7 @@ postcss-selector-parser@^2.0.0, postcss-selector-parser@^2.2.2:
indexes-of "^1.0.1"
uniq "^1.0.1"
postcss-selector-parser@^3.1.0:
postcss-selector-parser@^3.1.0, postcss-selector-parser@^3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-3.1.1.tgz#4f875f4afb0c96573d5cf4d74011aee250a7e865"
dependencies:
@ -5786,7 +5852,7 @@ postcss@^5.0.10, postcss@^5.0.11, postcss@^5.0.12, postcss@^5.0.13, postcss@^5.0
source-map "^0.5.6"
supports-color "^3.2.3"
postcss@^6.0.1, postcss@^6.0.23:
postcss@^6.0.1, postcss@^6.0.20, postcss@^6.0.23:
version "6.0.23"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-6.0.23.tgz#61c82cc328ac60e677645f979054eb98bc0e3324"
dependencies:
@ -5818,6 +5884,10 @@ prepend-http@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897"
prettier@^1.13.0:
version "1.13.7"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.13.7.tgz#850f3b8af784a49a6ea2d2eaa7ed1428a34b7281"
primer-support@5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/primer-support/-/primer-support-5.0.0.tgz#d19c7cea59e8783400b9391943c8a2bb2ebddc5e"
@ -6649,6 +6719,10 @@ sort-keys@^1.0.0:
dependencies:
is-plain-obj "^1.0.0"
sortablejs@^1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/sortablejs/-/sortablejs-1.7.0.tgz#80a2b2370abd568e1cec8c271131ef30a904fa28"
source-list-map@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34"
@ -7567,6 +7641,59 @@ vm-browserify@0.0.4:
dependencies:
indexof "0.0.1"
vue-eslint-parser@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-2.0.3.tgz#c268c96c6d94cfe3d938a5f7593959b0ca3360d1"
dependencies:
debug "^3.1.0"
eslint-scope "^3.7.1"
eslint-visitor-keys "^1.0.0"
espree "^3.5.2"
esquery "^1.0.0"
lodash "^4.17.4"
vue-hot-reload-api@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/vue-hot-reload-api/-/vue-hot-reload-api-2.3.0.tgz#97976142405d13d8efae154749e88c4e358cf926"
vue-loader@15.2.4:
version "15.2.4"
resolved "https://registry.yarnpkg.com/vue-loader/-/vue-loader-15.2.4.tgz#a7b923123d3cf87230a8ff54a1c16d31a6c5dbb4"
dependencies:
"@vue/component-compiler-utils" "^1.2.1"
hash-sum "^1.0.2"
loader-utils "^1.1.0"
vue-hot-reload-api "^2.3.0"
vue-style-loader "^4.1.0"
vue-style-loader@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/vue-style-loader/-/vue-style-loader-4.1.0.tgz#7588bd778e2c9f8d87bfc3c5a4a039638da7a863"
dependencies:
hash-sum "^1.0.2"
loader-utils "^1.0.2"
vue-template-compiler@2.5.16:
version "2.5.16"
resolved "https://registry.yarnpkg.com/vue-template-compiler/-/vue-template-compiler-2.5.16.tgz#93b48570e56c720cdf3f051cc15287c26fbd04cb"
dependencies:
de-indent "^1.0.2"
he "^1.1.0"
vue-template-es2015-compiler@^1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.6.0.tgz#dc42697133302ce3017524356a6c61b7b69b4a18"
vue@2.5.16:
version "2.5.16"
resolved "https://registry.yarnpkg.com/vue/-/vue-2.5.16.tgz#07edb75e8412aaeed871ebafa99f4672584a0085"
vuedraggable@2.16.0:
version "2.16.0"
resolved "https://registry.yarnpkg.com/vuedraggable/-/vuedraggable-2.16.0.tgz#52127081a2adb3de5fabd214d404ff3eee63575a"
dependencies:
sortablejs "^1.7.0"
watchpack@^1.5.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.6.0.tgz#4bc12c2ebe8aa277a71f1d3f14d685c7b446cd00"