<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"> <Username v-for="user in users" :key="user.original.nick" :on-hover="hoverUser" :active="user.original === activeUser" :user="user.original" v-html="user.original.mode + user.string" /> </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> import {filter as fuzzyFilter} from "fuzzy"; import Username from "./Username.vue"; const modes = { "~": "owner", "&": "admin", "!": "admin", "@": "op", "%": "half-op", "+": "voice", "": "normal", }; export default { name: "ChatUserList", components: { Username, }, 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() { if (!this.userSearchInput) { return; } return fuzzyFilter(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.left, clientY: rect.top + 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(); event.preventDefault(); 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>