Implement jump to channel feature.
This commit is contained in:
parent
fa57814678
commit
0b5cbceffd
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<ChannelWrapper ref="wrapper" :network="network" :channel="channel">
|
<ChannelWrapper ref="wrapper" v-bind="$props">
|
||||||
<span class="name">{{ channel.name }}</span>
|
<span class="name">{{ channel.name }}</span>
|
||||||
<span v-if="channel.unread" :class="{highlight: channel.highlight}" class="badge">{{
|
<span v-if="channel.unread" :class="{highlight: channel.highlight}" class="badge">{{
|
||||||
unreadCount
|
unreadCount
|
||||||
@ -36,6 +36,8 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
network: Object,
|
network: Object,
|
||||||
channel: Object,
|
channel: Object,
|
||||||
|
active: Boolean,
|
||||||
|
isFiltering: Boolean,
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
unreadCount() {
|
unreadCount() {
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
ref="element"
|
ref="element"
|
||||||
:class="[
|
:class="[
|
||||||
'channel-list-item',
|
'channel-list-item',
|
||||||
{active: activeChannel && channel === activeChannel.channel},
|
{active: active},
|
||||||
{'parted-channel': channel.type === 'channel' && channel.state === 0},
|
{'parted-channel': channel.type === 'channel' && channel.state === 0},
|
||||||
{'has-draft': channel.pendingMessage},
|
{'has-draft': channel.pendingMessage},
|
||||||
{
|
{
|
||||||
@ -19,7 +19,7 @@
|
|||||||
:data-name="channel.name"
|
:data-name="channel.name"
|
||||||
:data-type="channel.type"
|
:data-type="channel.type"
|
||||||
:aria-controls="'#chan-' + channel.id"
|
:aria-controls="'#chan-' + channel.id"
|
||||||
:aria-selected="activeChannel && channel === activeChannel.channel"
|
:aria-selected="active"
|
||||||
:style="channel.closed ? {transition: 'none', opacity: 0.4} : null"
|
:style="channel.closed ? {transition: 'none', opacity: 0.4} : null"
|
||||||
role="tab"
|
role="tab"
|
||||||
@click="click"
|
@click="click"
|
||||||
@ -37,13 +37,15 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
network: Object,
|
network: Object,
|
||||||
channel: Object,
|
channel: Object,
|
||||||
|
active: Boolean,
|
||||||
|
isFiltering: Boolean,
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
activeChannel() {
|
activeChannel() {
|
||||||
return this.$store.state.activeChannel;
|
return this.$store.state.activeChannel;
|
||||||
},
|
},
|
||||||
isChannelVisible() {
|
isChannelVisible() {
|
||||||
return !isChannelCollapsed(this.network, this.channel);
|
return this.isFiltering || !isChannelCollapsed(this.network, this.channel);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
@ -2,71 +2,125 @@
|
|||||||
<div v-if="networks.length === 0" class="empty">
|
<div v-if="networks.length === 0" class="empty">
|
||||||
You are not connected to any networks yet.
|
You are not connected to any networks yet.
|
||||||
</div>
|
</div>
|
||||||
<Draggable
|
<div v-else>
|
||||||
v-else
|
<div class="jump-to-input">
|
||||||
:list="networks"
|
<input
|
||||||
:filter="isCurrentlyInTouch"
|
ref="searchInput"
|
||||||
:prevent-on-filter="false"
|
:value="searchText"
|
||||||
handle=".channel-list-item[data-type='lobby']"
|
placeholder="Jump to..."
|
||||||
draggable=".network"
|
type="search"
|
||||||
ghost-class="ui-sortable-ghost"
|
class="search input mousetrap"
|
||||||
drag-class="ui-sortable-dragged"
|
aria-label="Search among the channel list"
|
||||||
group="networks"
|
tabindex="-1"
|
||||||
class="networks"
|
@input="setSearchText"
|
||||||
@change="onNetworkSort"
|
@keydown.up="navigateResults($event, -1)"
|
||||||
@start="onDragStart"
|
@keydown.down="navigateResults($event, 1)"
|
||||||
@end="onDragEnd"
|
@keydown.page-up="navigateResults($event, -10)"
|
||||||
>
|
@keydown.page-down="navigateResults($event, 10)"
|
||||||
<div
|
@keydown.enter="selectResult"
|
||||||
v-for="network in networks"
|
@keydown.escape="deactivateSearch"
|
||||||
:id="'network-' + network.uuid"
|
@focus="activateSearch"
|
||||||
:key="network.uuid"
|
|
||||||
:class="{
|
|
||||||
collapsed: network.isCollapsed,
|
|
||||||
}"
|
|
||||||
class="network"
|
|
||||||
role="region"
|
|
||||||
>
|
|
||||||
<NetworkLobby
|
|
||||||
:network="network"
|
|
||||||
: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
|
|
||||||
draggable=".channel-list-item"
|
|
||||||
ghost-class="ui-sortable-ghost"
|
|
||||||
drag-class="ui-sortable-dragged"
|
|
||||||
:group="network.uuid"
|
|
||||||
:filter="isCurrentlyInTouch"
|
|
||||||
:prevent-on-filter="false"
|
|
||||||
: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"
|
|
||||||
/>
|
|
||||||
</Draggable>
|
|
||||||
</div>
|
</div>
|
||||||
</Draggable>
|
<div v-if="searchText" class="jump-to-results">
|
||||||
|
<div v-if="results.length" ref="results">
|
||||||
|
<div
|
||||||
|
v-for="item in results"
|
||||||
|
:key="item.channel.id"
|
||||||
|
v-on="{mouseover: () => setActiveSearchItem(item.channel)}"
|
||||||
|
@click.prevent="selectResult"
|
||||||
|
>
|
||||||
|
<Channel
|
||||||
|
v-if="item.channel.type !== 'lobby'"
|
||||||
|
:channel="item.channel"
|
||||||
|
:network="item.network"
|
||||||
|
:active="item.channel === activeSearchItem"
|
||||||
|
:is-filtering="true"
|
||||||
|
/>
|
||||||
|
<NetworkLobby
|
||||||
|
v-else
|
||||||
|
:channel="item.channel"
|
||||||
|
:network="item.network"
|
||||||
|
:active="item.channel === activeSearchItem"
|
||||||
|
:is-filtering="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="no-results">
|
||||||
|
No results found.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Draggable
|
||||||
|
v-else
|
||||||
|
:list="networks"
|
||||||
|
:filter="isCurrentlyInTouch"
|
||||||
|
:prevent-on-filter="false"
|
||||||
|
handle=".channel-list-item[data-type='lobby']"
|
||||||
|
draggable=".network"
|
||||||
|
ghost-class="ui-sortable-ghost"
|
||||||
|
drag-class="ui-sortable-dragged"
|
||||||
|
group="networks"
|
||||||
|
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,
|
||||||
|
}"
|
||||||
|
class="network"
|
||||||
|
role="region"
|
||||||
|
>
|
||||||
|
<NetworkLobby
|
||||||
|
:network="network"
|
||||||
|
:is-join-channel-shown="network.isJoinChannelShown"
|
||||||
|
:active="activeChannel && network.channels[0] === activeChannel.channel"
|
||||||
|
@toggleJoinChannel="network.isJoinChannelShown = !network.isJoinChannelShown"
|
||||||
|
/>
|
||||||
|
<JoinChannel
|
||||||
|
v-if="network.isJoinChannelShown"
|
||||||
|
:network="network"
|
||||||
|
:channel="network.channels[0]"
|
||||||
|
@toggleJoinChannel="network.isJoinChannelShown = !network.isJoinChannelShown"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Draggable
|
||||||
|
draggable=".channel-list-item"
|
||||||
|
ghost-class="ui-sortable-ghost"
|
||||||
|
drag-class="ui-sortable-dragged"
|
||||||
|
:group="network.uuid"
|
||||||
|
:filter="isCurrentlyInTouch"
|
||||||
|
:prevent-on-filter="false"
|
||||||
|
: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="activeChannel && channel === activeChannel.channel"
|
||||||
|
/>
|
||||||
|
</Draggable>
|
||||||
|
</div>
|
||||||
|
</Draggable>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import Mousetrap from "mousetrap";
|
import Mousetrap from "mousetrap";
|
||||||
import Draggable from "vuedraggable";
|
import Draggable from "vuedraggable";
|
||||||
|
import {filter as fuzzyFilter} from "fuzzy";
|
||||||
import NetworkLobby from "./NetworkLobby.vue";
|
import NetworkLobby from "./NetworkLobby.vue";
|
||||||
import Channel from "./Channel.vue";
|
import Channel from "./Channel.vue";
|
||||||
import JoinChannel from "./JoinChannel.vue";
|
import JoinChannel from "./JoinChannel.vue";
|
||||||
@ -82,10 +136,55 @@ export default {
|
|||||||
Channel,
|
Channel,
|
||||||
Draggable,
|
Draggable,
|
||||||
},
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
searchText: "",
|
||||||
|
activeSearchItem: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
activeChannel() {
|
||||||
|
return this.$store.state.activeChannel;
|
||||||
|
},
|
||||||
networks() {
|
networks() {
|
||||||
return this.$store.state.networks;
|
return this.$store.state.networks;
|
||||||
},
|
},
|
||||||
|
items() {
|
||||||
|
const items = [];
|
||||||
|
|
||||||
|
for (const network of this.$store.state.networks) {
|
||||||
|
for (const channel of network.channels) {
|
||||||
|
if (
|
||||||
|
this.$store.state.activeChannel &&
|
||||||
|
channel === this.$store.state.activeChannel.channel
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({network, channel});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
},
|
||||||
|
results() {
|
||||||
|
const results = fuzzyFilter(this.searchText, this.items, {
|
||||||
|
extract: (item) => item.channel.name,
|
||||||
|
}).map((item) => item.original);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
searchText() {
|
||||||
|
this.setActiveSearchItem();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
Mousetrap.bind("alt+j", this.toggleSearch);
|
||||||
|
},
|
||||||
|
destroyed() {
|
||||||
|
Mousetrap.unbind("alt+j", this.toggleSearch);
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
Mousetrap.bind("alt+shift+right", this.expandNetwork);
|
Mousetrap.bind("alt+shift+right", this.expandNetwork);
|
||||||
@ -143,6 +242,110 @@ export default {
|
|||||||
order: channel.network.channels.map((c) => c.id),
|
order: channel.network.channels.map((c) => c.id),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
toggleSearch(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (this.$refs.searchInput === document.activeElement) {
|
||||||
|
this.closeSearch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.activateSearch();
|
||||||
|
},
|
||||||
|
activateSearch() {
|
||||||
|
if (this.$refs.searchInput === document.activeElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sidebarWasOpen = this.$store.state.sidebarOpen;
|
||||||
|
this.$store.commit("sidebarOpen", true);
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.$refs.searchInput.focus();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
deactivateSearch() {
|
||||||
|
this.activeSearchItem = null;
|
||||||
|
this.searchText = "";
|
||||||
|
this.$refs.searchInput.blur();
|
||||||
|
|
||||||
|
if (!this.sidebarWasOpen) {
|
||||||
|
this.$store.commit("sidebarOpen", false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setSearchText(e) {
|
||||||
|
this.searchText = e.target.value;
|
||||||
|
},
|
||||||
|
setActiveSearchItem(channel) {
|
||||||
|
if (!this.results.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!channel) {
|
||||||
|
channel = this.results[0].channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.activeSearchItem = channel;
|
||||||
|
},
|
||||||
|
selectResult() {
|
||||||
|
if (!this.searchText || !this.results.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$root.switchToChannel(this.activeSearchItem);
|
||||||
|
this.deactivateSearch();
|
||||||
|
},
|
||||||
|
navigateResults(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();
|
||||||
|
|
||||||
|
if (!this.searchText) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channels = this.results.map((r) => r.channel);
|
||||||
|
|
||||||
|
// Bail out if there's no channels to select
|
||||||
|
if (!channels.length) {
|
||||||
|
this.activeSearchItem = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentIndex = channels.indexOf(this.activeSearchItem);
|
||||||
|
|
||||||
|
// If there's no active channel select the first or last one depending on direction
|
||||||
|
if (!this.activeSearchItem || currentIndex === -1) {
|
||||||
|
this.activeSearchItem = direction ? channels[0] : channels[channels.length - 1];
|
||||||
|
this.scrollToActive();
|
||||||
|
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 list
|
||||||
|
while (currentIndex < 0) {
|
||||||
|
currentIndex += channels.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (currentIndex > channels.length - 1) {
|
||||||
|
currentIndex -= channels.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.activeSearchItem = channels[currentIndex];
|
||||||
|
this.scrollToActive();
|
||||||
|
},
|
||||||
|
scrollToActive() {
|
||||||
|
// Scroll the list if needed after the active class is applied
|
||||||
|
this.$nextTick(() => {
|
||||||
|
const el = this.$refs.results.querySelector(".active-result");
|
||||||
|
|
||||||
|
if (el) {
|
||||||
|
el.scrollIntoView({block: "nearest", inline: "nearest"});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<ChannelWrapper :network="network" :channel="channel">
|
<ChannelWrapper v-bind="$props" :channel="channel">
|
||||||
<button
|
<button
|
||||||
v-if="network.channels.length > 1"
|
v-if="network.channels.length > 1"
|
||||||
:aria-controls="'network-' + network.uuid"
|
:aria-controls="'network-' + network.uuid"
|
||||||
@ -58,6 +58,8 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
network: Object,
|
network: Object,
|
||||||
isJoinChannelShown: Boolean,
|
isJoinChannelShown: Boolean,
|
||||||
|
active: Boolean,
|
||||||
|
isFiltering: Boolean,
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
channel() {
|
channel() {
|
||||||
|
@ -325,6 +325,7 @@ p {
|
|||||||
.channel-list-item .not-secure-icon::before,
|
.channel-list-item .not-secure-icon::before,
|
||||||
.channel-list-item .not-connected-icon::before,
|
.channel-list-item .not-connected-icon::before,
|
||||||
.channel-list-item .parted-channel-icon::before,
|
.channel-list-item .parted-channel-icon::before,
|
||||||
|
.jump-to-input::before,
|
||||||
#sidebar .collapse-network-icon::before {
|
#sidebar .collapse-network-icon::before {
|
||||||
font: normal normal normal 14px/1 FontAwesome;
|
font: normal normal normal 14px/1 FontAwesome;
|
||||||
font-size: inherit; /* Can't have font-size inherit on line above, so need to override */
|
font-size: inherit; /* Can't have font-size inherit on line above, so need to override */
|
||||||
@ -510,6 +511,7 @@ p {
|
|||||||
content: "\f0da"; /* http://fontawesome.io/icon/caret-right/ */
|
content: "\f0da"; /* http://fontawesome.io/icon/caret-right/ */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.jump-to-input::before,
|
||||||
#chat .count::before {
|
#chat .count::before {
|
||||||
color: #cfcfcf;
|
color: #cfcfcf;
|
||||||
content: "\f002"; /* http://fontawesome.io/icon/search/ */
|
content: "\f002"; /* http://fontawesome.io/icon/search/ */
|
||||||
@ -618,6 +620,7 @@ p {
|
|||||||
width: 220px;
|
width: 220px;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
|
color: #b7c5d1; /* same as .channel-list-item color */
|
||||||
}
|
}
|
||||||
|
|
||||||
#viewport.menu-open #sidebar {
|
#viewport.menu-open #sidebar {
|
||||||
@ -648,7 +651,6 @@ p {
|
|||||||
|
|
||||||
.channel-list-item,
|
.channel-list-item,
|
||||||
#sidebar .empty {
|
#sidebar .empty {
|
||||||
color: #b7c5d1;
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -703,10 +705,6 @@ background on hover (unless active) */
|
|||||||
right: 10px;
|
right: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#sidebar .networks {
|
|
||||||
padding-top: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#sidebar .network {
|
#sidebar .network {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
@ -2807,3 +2805,61 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
|||||||
#chat table.channel-list .topic {
|
#chat table.channel-list .topic {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Jump to feature WIP styles */
|
||||||
|
.jump-to-input {
|
||||||
|
margin: 8px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-to-input .input {
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
color: inherit;
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
padding-right: 35px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-to-input .input::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-to-input::before {
|
||||||
|
color: rgba(255, 255, 255, 0.35);
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
line-height: 35px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-to-results {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-to-results .no-results {
|
||||||
|
margin: 14px 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-to-results .channel-list-item.active {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-to-results .channel-list-item .add-channel,
|
||||||
|
.jump-to-results .channel-list-item .close-tooltip {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-to-results .channel-list-item[data-type="lobby"] {
|
||||||
|
padding: 8px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-to-results .channel-list-item[data-type="lobby"]::before {
|
||||||
|
content: "\f233";
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user