mirror of
https://github.com/thelounge/thelounge.git
synced 2024-05-08 17:26:42 +02:00
373 lines
7.7 KiB
Vue
373 lines
7.7 KiB
Vue
<template>
|
|
<aside
|
|
ref="userlist"
|
|
class="userlist"
|
|
:aria-label="'User list for ' + channel.name"
|
|
@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(String(mode))]"
|
|
>
|
|
<template v-if="userSearchInput.length > 0">
|
|
<Username
|
|
v-for="user in users"
|
|
:key="user.original.nick + '-search'"
|
|
:on-hover="hoverUser"
|
|
:active="user.original === activeUser"
|
|
:user="(user.original as any)"
|
|
:html="user.string"
|
|
:include-status-icon="true"
|
|
/>
|
|
</template>
|
|
<template v-else>
|
|
<Username
|
|
v-for="user in users"
|
|
:key="user.nick"
|
|
:on-hover="hoverUser"
|
|
:active="user === activeUser"
|
|
:user="user"
|
|
:include-status-icon="true"
|
|
/>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
</template>
|
|
|
|
<style>
|
|
.userlist {
|
|
border-left: 1px solid #e7e7e7;
|
|
width: 180px;
|
|
display: none;
|
|
flex-direction: column;
|
|
flex-shrink: 0;
|
|
touch-action: pan-y;
|
|
}
|
|
|
|
.userlist .count {
|
|
background: #fafafa;
|
|
height: 45px;
|
|
flex-shrink: 0;
|
|
position: relative;
|
|
}
|
|
.userlist .search {
|
|
color: var(--body-color);
|
|
appearance: none;
|
|
border: 0;
|
|
background: none;
|
|
font: inherit;
|
|
outline: 0;
|
|
padding: 13px;
|
|
padding-right: 30px;
|
|
width: 100%;
|
|
}
|
|
|
|
.userlist .names {
|
|
flex-grow: 1;
|
|
overflow: auto;
|
|
overflow-x: hidden;
|
|
padding-bottom: 10px;
|
|
width: 100%;
|
|
touch-action: pan-y;
|
|
scrollbar-width: thin;
|
|
overscroll-behavior: contain;
|
|
-webkit-overflow-scrolling: touch;
|
|
}
|
|
|
|
#viewport.userlist-open #chat .userlist {
|
|
display: flex;
|
|
}
|
|
|
|
#chat .names .user {
|
|
display: block;
|
|
line-height: 1.6;
|
|
padding: 0 16px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
#chat .user-mode {
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
#chat .user-mode::before {
|
|
background: var(--window-bg-color);
|
|
color: var(--body-color-muted);
|
|
display: block;
|
|
font-size: 0.85em;
|
|
line-height: 1.6;
|
|
padding: 5px 16px;
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 1;
|
|
}
|
|
|
|
#chat .user-mode.owner::before {
|
|
content: "Owners";
|
|
}
|
|
|
|
#chat .user-mode.admin::before {
|
|
content: "Administrators";
|
|
}
|
|
|
|
#chat .user-mode.op::before {
|
|
content: "Operators";
|
|
}
|
|
|
|
#chat .user-mode.half-op::before {
|
|
content: "Half-Operators";
|
|
}
|
|
|
|
#chat .user-mode.voice::before {
|
|
content: "Voiced";
|
|
}
|
|
|
|
#chat .user-mode.normal::before {
|
|
content: "Users";
|
|
}
|
|
|
|
#chat .user-mode-search::before {
|
|
content: "Search Results";
|
|
}
|
|
|
|
/* Status icon */
|
|
#chat .names .status {
|
|
margin-left: -3px;
|
|
margin-right: 2px;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
#chat .userlist {
|
|
background-color: var(--window-bg-color);
|
|
height: 100%;
|
|
position: absolute;
|
|
right: 0;
|
|
transform: translateX(180px);
|
|
transition: transform 0.2s;
|
|
}
|
|
|
|
#viewport.userlist-open #chat .userlist {
|
|
transform: translateX(0);
|
|
}
|
|
}
|
|
</style>
|
|
|
|
<script lang="ts">
|
|
import {filter as fuzzyFilter} from "fuzzy";
|
|
import {computed, defineComponent, nextTick, PropType, ref} from "vue";
|
|
import type {UserInMessage} from "../../src/models/msg";
|
|
import type {ClientChan, ClientUser} from "../js/types";
|
|
import Username from "./Username.vue";
|
|
|
|
const modes = {
|
|
"~": "owner",
|
|
"&": "admin",
|
|
"!": "admin",
|
|
"@": "op",
|
|
"%": "half-op",
|
|
"+": "voice",
|
|
"": "normal",
|
|
};
|
|
|
|
export default defineComponent({
|
|
name: "ChatUserList",
|
|
components: {
|
|
Username,
|
|
},
|
|
props: {
|
|
channel: {type: Object as PropType<ClientChan>, required: true},
|
|
},
|
|
setup(props) {
|
|
const userSearchInput = ref("");
|
|
const activeUser = ref<UserInMessage | null>();
|
|
const userlist = ref<HTMLDivElement>();
|
|
const filteredUsers = computed(() => {
|
|
if (!userSearchInput.value) {
|
|
return;
|
|
}
|
|
|
|
return fuzzyFilter(userSearchInput.value, props.channel.users, {
|
|
pre: "<b>",
|
|
post: "</b>",
|
|
extract: (u) => u.nick,
|
|
});
|
|
});
|
|
|
|
const groupedUsers = computed(() => {
|
|
const groups = {};
|
|
|
|
if (userSearchInput.value && filteredUsers.value) {
|
|
const result = filteredUsers.value;
|
|
|
|
for (const user of result) {
|
|
const mode = user.original.modes[0] || "";
|
|
|
|
if (!groups[mode]) {
|
|
groups[mode] = [];
|
|
}
|
|
|
|
// Prepend user mode to search result
|
|
user.string = mode + user.string;
|
|
|
|
groups[mode].push(user);
|
|
}
|
|
} else {
|
|
for (const user of props.channel.users) {
|
|
const mode = user.modes[0] || "";
|
|
|
|
if (!groups[mode]) {
|
|
groups[mode] = [user];
|
|
} else {
|
|
groups[mode].push(user);
|
|
}
|
|
}
|
|
}
|
|
|
|
return groups as {
|
|
[mode: string]: (ClientUser & {
|
|
original: UserInMessage;
|
|
string: string;
|
|
})[];
|
|
};
|
|
});
|
|
|
|
const setUserSearchInput = (e: Event) => {
|
|
userSearchInput.value = (e.target as HTMLInputElement).value;
|
|
};
|
|
|
|
const getModeClass = (mode: string) => {
|
|
return modes[mode] as typeof modes;
|
|
};
|
|
|
|
const selectUser = () => {
|
|
// Simulate a click on the active user to open the context menu.
|
|
// Coordinates are provided to position the menu correctly.
|
|
if (!activeUser.value || !userlist.value) {
|
|
return;
|
|
}
|
|
|
|
const el = userlist.value.querySelector(".active");
|
|
|
|
if (!el) {
|
|
return;
|
|
}
|
|
|
|
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);
|
|
};
|
|
|
|
const hoverUser = (user: UserInMessage) => {
|
|
activeUser.value = user;
|
|
};
|
|
|
|
const removeHoverUser = () => {
|
|
activeUser.value = null;
|
|
};
|
|
|
|
const scrollToActiveUser = () => {
|
|
// Scroll the list if needed after the active class is applied
|
|
void nextTick(() => {
|
|
const el = userlist.value?.querySelector(".active");
|
|
el?.scrollIntoView({block: "nearest", inline: "nearest"});
|
|
});
|
|
};
|
|
|
|
const navigateUserList = (event: Event, direction: number) => {
|
|
// 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 = props.channel.users;
|
|
|
|
// Only using filteredUsers when we have to avoids filtering when it's not needed
|
|
if (userSearchInput.value && filteredUsers.value) {
|
|
users = filteredUsers.value.map((result) => result.original);
|
|
}
|
|
|
|
// Bail out if there's no users to select
|
|
if (!users.length) {
|
|
activeUser.value = null;
|
|
return;
|
|
}
|
|
|
|
const abort = () => {
|
|
activeUser.value = direction ? users[0] : users[users.length - 1];
|
|
scrollToActiveUser();
|
|
};
|
|
|
|
// If there's no active user select the first or last one depending on direction
|
|
if (!activeUser.value) {
|
|
abort();
|
|
return;
|
|
}
|
|
|
|
let currentIndex = users.indexOf(activeUser.value as ClientUser);
|
|
|
|
if (currentIndex === -1) {
|
|
abort();
|
|
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;
|
|
}
|
|
|
|
activeUser.value = users[currentIndex];
|
|
scrollToActiveUser();
|
|
};
|
|
|
|
return {
|
|
filteredUsers,
|
|
groupedUsers,
|
|
userSearchInput,
|
|
activeUser,
|
|
userlist,
|
|
|
|
setUserSearchInput,
|
|
getModeClass,
|
|
selectUser,
|
|
hoverUser,
|
|
removeHoverUser,
|
|
navigateUserList,
|
|
};
|
|
},
|
|
});
|
|
</script>
|