Single chat container (with buffered input), user list in vue

This commit is contained in:
Pavel Djundik 2018-07-08 15:18:17 +03:00 committed by Pavel Djundik
parent e931866aeb
commit 25840dfef4
11 changed files with 217 additions and 306 deletions

View File

@ -59,38 +59,7 @@
</aside> </aside>
<div id="sidebar-overlay"/> <div id="sidebar-overlay"/>
<article id="windows"> <article id="windows">
<div <Chat v-if="activeChannel" :network="activeChannel.network" :channel="activeChannel.channel"/>
id="chat-container"
class="window">
<div id="chat">
<!--Chat v-if="activeChannel" :channel="activeChannel.channel"/-->
<template v-for="network in networks">
<Chat
v-for="channel in network.channels"
:key="channel.id"
:channel="channel"/>
</template>
</div>
<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 <div
id="sign-in" id="sign-in"
class="window" class="window"

View File

@ -1,99 +1,164 @@
<template> <template>
<div <div id="chat-container" class="window">
:id="'chan-' + channel.id" <div id="chat">
:class="[channel.type, 'chan']" <div
:data-id="channel.id" :id="'chan-' + channel.id"
:data-type="channel.type" :class="[channel.type, 'chan', 'active']"
:aria-label="channel.name" :data-id="channel.id"
role="tabpanel" :data-type="channel.type"
> :aria-label="channel.name"
<div class="header"> role="tabpanel"
<button >
class="lt" <div class="header">
aria-label="Toggle channel list"/>
<span class="title">{{ channel.name }}</span>
<span
:title="channel.topic"
class="topic"
v-html="$options.filters.parse(channel.topic)"/>
<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 class="chat-content">
<div class="chat">
<div
v-if="channel.messages.length > 0"
class="show-more">
<button <button
:data-id="channel.id" class="lt"
class="btn" aria-label="Toggle channel list"/>
data-alt-text="Loading…">Show older messages</button> <span class="title">{{ channel.name }}</span>
<span
:title="channel.topic"
class="topic"
v-html="$options.filters.parse(channel.topic)"/>
<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>
<div <div class="chat-content">
class="messages" <div class="chat">
role="log"
aria-live="polite"
aria-relevant="additions"
>
<template v-for="(message, id) in channel.messages">
<div <div
v-if="shouldDisplayDateMarker(id)" v-if="channel.messages.length > 0"
:key="message.id + '-date'" class="show-more show">
:data-time="message.time" <button
:aria-label="message.time | localedate" :data-id="channel.id"
class="date-marker-container tooltipped tooltipped-s" class="btn"
data-alt-text="Loading…">Show older messages</button>
</div>
<div
class="messages"
role="log"
aria-live="polite"
aria-relevant="additions"
> >
<div class="date-marker"> <template v-for="(message, id) in channel.messages">
<span <div
:data-label="message.time | friendlydate" v-if="shouldDisplayDateMarker(id)"
class="date-marker-text"/> :key="message.id + '-date'"
:data-time="message.time"
:aria-label="message.time | localedate"
class="date-marker-container tooltipped tooltipped-s"
>
<div class="date-marker">
<span
:data-label="message.time | friendlydate"
class="date-marker-text"/>
</div>
</div>
<div
v-if="shouldDisplayUnreadMarker(message.id)"
:key="message.id + '-unread'"
class="unread-marker"
>
<span class="unread-marker-text"/>
</div>
<Message
:message="message"
:key="message.id"/>
</template>
</div>
</div>
<aside
v-if="channel.type === 'channel'"
class="userlist">
<div class="count">
<input
:placeholder="channel.users.length + ' user' + (channel.users.length === 1 ? '' : 's')"
type="search"
class="search"
aria-label="Search among the user list"
tabindex="-1">
</div>
<div class="names">
<div v-for="(users, mode) in groupedUsers" :key="mode" :class="['user-mode', getModeClass(mode)]">
<Username v-for="user in users" :key="user.nick" :user="user"/>
</div> </div>
</div> </div>
</aside>
<Message
:message="message"
:key="message.id"/>
</template>
</div> </div>
</div> </div>
<aside
v-if="channel.type === 'channel'"
class="userlist">
<div class="count">
<input
type="search"
class="search"
aria-label="Search among the user list"
tabindex="-1">
</div>
<div class="names names-filtered"/>
<div class="names names-original"/>
</aside>
</div> </div>
<div id="connection-error"/>
<form
id="form"
method="post"
action="">
<span id="nick">{{network.nick}}</span>
<textarea
id="input"
class="mousetrap"
v-model="channel.pendingMessage"
:placeholder="getInputPlaceholder(channel)"
:aria-label="getInputPlaceholder(channel)"
/>
<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>
</template> </template>
<script> <script>
import Message from "./Message.vue"; import Message from "./Message.vue";
import Username from "./Username.vue";
const modes = {
"~": "owner",
"&": "admin",
"!": "admin",
"@": "op",
"%": "half-op",
"+": "voice",
"": "normal",
};
export default { export default {
name: "Chat", name: "Chat",
components: { components: {
Message, Message,
Username,
}, },
props: { props: {
network: Object,
channel: Object, channel: Object,
}, },
computed: {
groupedUsers() {
const groups = {};
for (const user of this.channel.users) {
if (!groups[user.mode]) {
groups[user.mode] = [user];
} else {
groups[user.mode].push(user);
}
}
return groups;
},
},
methods: { methods: {
shouldDisplayDateMarker(id) { shouldDisplayDateMarker(id) {
const previousTime = this.channel.messages[id - 1]; const previousTime = this.channel.messages[id - 1];
@ -106,6 +171,25 @@ export default {
return (new Date(previousTime.time)).getDay() !== (new Date(currentTime.time)).getDay(); return (new Date(previousTime.time)).getDay() !== (new Date(currentTime.time)).getDay();
}, },
shouldDisplayUnreadMarker(msgId) {
if (this.channel.firstUnread < msgId) {
return false;
}
this.channel.firstUnread = 0;
return true;
},
getInputPlaceholder(channel) {
if (channel.type === "channel" || channel.type === "query") {
return `Write to ${channel.name}`;
}
return "";
},
getModeClass(mode) {
return modes[mode];
},
}, },
}; };
</script> </script>

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

@ -164,6 +164,7 @@ $(function() {
// sidebar specifically. Needs to be done better when window management gets // sidebar specifically. Needs to be done better when window management gets
// refactored. // refactored.
const inSidebar = self.parents("#sidebar, #footer").length > 0; const inSidebar = self.parents("#sidebar, #footer").length > 0;
let channel;
if (inSidebar) { if (inSidebar) {
chat.data( chat.data(
@ -175,7 +176,7 @@ $(function() {
self.data("id") self.data("id")
); );
const channel = findChannel(self.data("id")); channel = findChannel(self.data("id"));
vueApp.activeChannel = channel; vueApp.activeChannel = channel;
@ -207,11 +208,9 @@ $(function() {
const lastActive = $("#windows > .active"); const lastActive = $("#windows > .active");
lastActive lastActive
.removeClass("active") .removeClass("active");
.find(".chat")
.unsticky();
const lastActiveChan = lastActive.find(".chan.active"); /*const lastActiveChan = lastActive.find(".chan.active");
if (lastActiveChan.length > 0) { if (lastActiveChan.length > 0) {
lastActiveChan lastActiveChan
@ -221,7 +220,7 @@ $(function() {
.appendTo(lastActiveChan.find(".messages")); .appendTo(lastActiveChan.find(".messages"));
render.trimMessageInChannel(lastActiveChan, 100); render.trimMessageInChannel(lastActiveChan, 100);
} }*/
const chan = $(target) const chan = $(target)
.addClass("active") .addClass("active")
@ -231,19 +230,9 @@ $(function() {
utils.updateTitle(); utils.updateTitle();
const type = chan.data("type"); 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);
if (self.hasClass("chan")) { if (self.hasClass("chan")) {
$("#chat-container").addClass("active"); $("#chat-container").addClass("active");
$("#nick").text(self.closest(".network").attr("data-nick"));
} }
const chanChat = chan.find(".chat"); const chanChat = chan.find(".chat");
@ -257,9 +246,12 @@ $(function() {
input.trigger("ontouchstart" in window ? "blur" : "focus"); input.trigger("ontouchstart" in window ? "blur" : "focus");
} }
if (chan.data("needsNamesRefresh") === true) { if (channel && channel.channel.usersOutdated) {
chan.data("needsNamesRefresh", false); channel.channel.usersOutdated = false;
socket.emit("names", {target: self.data("id")});
socket.emit("names", {
target: channel.channel.id,
});
} }
// Pushes states to history web API when clicking elements with a data-target attribute. // Pushes states to history web API when clicking elements with a data-target attribute.

View File

@ -24,26 +24,18 @@ const historyObserver = window.IntersectionObserver ?
}) : null; }) : null;
module.exports = { module.exports = {
appendMessage,
buildChannelMessages,
renderChannel, renderChannel,
renderChannelUsers,
renderNetworks, renderNetworks,
trimMessageInChannel, 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) { function appendMessage(container, chanId, chanType, msg) {
if (utils.lastMessageId < msg.id) { if (utils.lastMessageId < msg.id) {
utils.lastMessageId = msg.id; utils.lastMessageId = msg.id;
} }
return;
let lastChild = container.children(".msg, .date-marker-container").last(); let lastChild = container.children(".msg, .date-marker-container").last();
const renderedMessage = buildChatMessage(msg); const renderedMessage = buildChatMessage(msg);
@ -136,80 +128,18 @@ function renderChannel(data) {
renderChannelMessages(data); renderChannelMessages(data);
if (data.type === "channel") { if (data.type === "channel") {
const users = renderChannelUsers(data); //const users = renderChannelUsers(data);
Userlist.handleKeybinds(users.find(".search")); //Userlist.handleKeybinds(users.find(".search"));
} }
if (historyObserver) { if (historyObserver) {
historyObserver.observe(chat.find("#chan-" + data.id + " .show-more").get(0)); //historyObserver.observe(chat.find("#chan-" + data.id + " .show-more").get(0));
} }
} }
function renderChannelMessages(data) { function renderChannelMessages(data) {
const documentFragment = buildChannelMessages($(document.createDocumentFragment()), data.id, data.type, data.messages);
const channel = chat.find("#chan-" + data.id + " .messages"); const channel = chat.find("#chan-" + data.id + " .messages");
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) { function renderNetworks(data, singleNetwork) {
@ -234,20 +164,12 @@ function renderNetworks(data, singleNetwork) {
const chan = $("#chan-" + channel.id); const chan = $("#chan-" + channel.id);
if (chan.length > 0) { if (chan.length > 0) {
if (chan.data("type") === "channel") { if (channel.type === "channel") {
chan channel.usersOutdated = true;
.data("needsNamesRefresh", true)
.find(".header .topic")
.html(helpers_parse(channel.topic))
.prop("title", channel.topic);
} }
if (channel.messages.length > 0) { if (channel.messages.length > 0) {
const container = chan.find(".messages"); 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) { if (container.find(".msg").length >= 100) {
container.find(".show-more").addClass("show"); container.find(".show-more").addClass("show");
@ -268,7 +190,7 @@ function renderNetworks(data, singleNetwork) {
renderChannel(channel); renderChannel(channel);
if (channel.type === "channel") { if (channel.type === "channel") {
chat.find("#chan-" + channel.id).data("needsNamesRefresh", true); channel.usersOutdated = true;
} }
}); });
} }

View File

@ -5,6 +5,7 @@ const socket = require("../socket");
const render = require("../render"); const render = require("../render");
const condensed = require("../condensed"); const condensed = require("../condensed");
const chat = $("#chat"); const chat = $("#chat");
const {Vue, vueApp, findChannel} = require("../vue");
socket.on("more", function(data) { socket.on("more", function(data) {
let chan = chat.find("#chan-" + data.chan); let chan = chat.find("#chan-" + data.chan);
@ -21,52 +22,31 @@ socket.on("more", function(data) {
return; return;
} }
// Remove the date marker at the top if date does not change const channel = findChannel(data.chan);
const children = $(chan).children();
// Check the top-most element and the one after because if (!channel) {
// unread and date markers may switch positions return;
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 channel.channel.messages.unshift(...data.messages);
const documentFragment = render.buildChannelMessages($(document.createDocumentFragment()), data.chan, type, data.messages);
chan.prepend(documentFragment);
// Move unread marker to correct spot if needed Vue.nextTick(() => {
const unreadMarker = chan.find(".unread-marker"); // restore scroll position
const firstUnread = unreadMarker.data("unread-id"); const position = chan.height() - heightOld;
scrollable.finish().scrollTop(position);
});
if (firstUnread > 0) { if (data.messages.length !== 100) {
let first = chan.find("#msg-" + firstUnread); scrollable.find(".show-more").removeClass("show");
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);
}
} }
// Swap button text back from its alternative label
const showMoreBtn = scrollable.find(".show-more button");
swapText(showMoreBtn);
showMoreBtn.prop("disabled", false);
return;
// Join duplicate condensed messages together // Join duplicate condensed messages together
const condensedDuplicate = chan.find(".msg.condensed + .msg.condensed"); const condensedDuplicate = chan.find(".msg.condensed + .msg.condensed");
@ -81,25 +61,6 @@ socket.on("more", function(data) {
condensedDuplicate.remove(); 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() { chat.on("click", ".show-more button", function() {

View File

@ -60,13 +60,7 @@ function processReceivedMessage(data) {
$(container).empty(); $(container).empty();
} }
// Add message to the container channel.channel.messages.push(data.msg);
render.appendMessage(
container,
targetId,
channelContainer.data("type"),
data.msg
);
if (activeChannelId === targetId) { if (activeChannelId === targetId) {
scrollContainer.trigger("keepToBottom"); scrollContainer.trigger("keepToBottom");

View File

@ -2,5 +2,12 @@
const socket = require("../socket"); const socket = require("../socket");
const render = require("../render"); const render = require("../render");
const {vueApp, 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

@ -1,14 +1,12 @@
"use strict"; "use strict";
const $ = require("jquery");
const socket = require("../socket"); const socket = require("../socket");
const {vueApp} = require("../vue");
socket.on("nick", function(data) { socket.on("nick", function(data) {
const id = data.network; const network = vueApp.networks.find((n) => n.uuid === data.network);
const nick = data.nick;
const network = $(`#sidebar .network[data-uuid="${id}"]`).attr("data-nick", nick);
if (network.find(".active").length) { if (network) {
$("#nick").text(nick); network.nick = data.nick;
} }
}); });

View File

@ -2,16 +2,18 @@
const $ = require("jquery"); const $ = require("jquery");
const socket = require("../socket"); const socket = require("../socket");
const chat = $("#chat"); const {vueApp, findChannel} = require("../vue");
socket.on("users", function(data) { socket.on("users", function(data) {
const chan = chat.find("#chan-" + data.chan); if (vueApp.activeChannel && vueApp.activeChannel.channel.id === data.chan) {
return socket.emit("names", {
if (chan.hasClass("active")) {
socket.emit("names", {
target: data.chan, target: data.chan,
}); });
} else { }
chan.data("needsNamesRefresh", true);
const channel = findChannel(data.chan);
if (channel) {
channel.channel.usersOutdated = true;
} }
}); });

View File

@ -1,3 +0,0 @@
<div class="unread-marker">
<span class="unread-marker-text"></span>
</div>