Add MONITOR support and status icons for queries

This commit is contained in:
Max Leiter 2022-06-01 22:35:01 -07:00
parent 8606d717aa
commit 5283d10cfb
No known key found for this signature in database
GPG key ID: A3512F2F2F17EBDA
22 changed files with 453 additions and 130 deletions

View file

@ -2,6 +2,11 @@
<!-- TODO: investigate -->
<ChannelWrapper ref="wrapper" v-bind="$props">
<span class="name">{{ channel.name }}</span>
<StatusIcon
v-if="channel.type === 'query' && network.status.connected"
:online="channel.isOnline"
:away="!!channel.userAway"
/>
<span
v-if="channel.unread"
:class="{highlight: channel.highlight && !channel.muted}"
@ -34,11 +39,13 @@ import roundBadgeNumber from "../js/helpers/roundBadgeNumber";
import useCloseChannel from "../js/hooks/use-close-channel";
import {ClientChan, ClientNetwork} from "../js/types";
import ChannelWrapper from "./ChannelWrapper.vue";
import StatusIcon from "./StatusIcon.vue";
export default defineComponent({
name: "Channel",
components: {
ChannelWrapper,
StatusIcon,
},
props: {
network: {

View file

@ -21,6 +21,12 @@
<span class="title" :aria-label="'Currently open ' + channel.type">{{
channel.name
}}</span>
<StatusIcon
v-if="channel.type === 'query'"
:online="channel.isOnline"
:away="!!channel.userAway"
tooltip-dir="e"
/>
<div v-if="channel.editTopic === true" class="topic-container">
<input
ref="topicInput"
@ -136,6 +142,7 @@ import ListIgnored from "./Special/ListIgnored.vue";
import {defineComponent, PropType, ref, computed, watch, nextTick, onMounted, Component} from "vue";
import type {ClientNetwork, ClientChan} from "../js/types";
import {useStore} from "../js/store";
import StatusIcon from "./StatusIcon.vue";
export default defineComponent({
name: "Chat",
@ -146,6 +153,7 @@ export default defineComponent({
ChatUserList,
SidebarToggle,
MessageSearchForm,
StatusIcon,
},
props: {
network: {type: Object as PropType<ClientNetwork>, required: true},

View file

@ -31,16 +31,15 @@
:class="['user-mode', getModeClass(String(mode))]"
>
<template v-if="userSearchInput.length > 0">
<!-- eslint-disable -->
<Username
v-for="user in users"
:key="user.original.nick + '-search'"
:on-hover="hoverUser"
:active="user.original === activeUser"
:user="(user.original as any)"
v-html="user.string"
:html="user.string"
:include-status-icon="true"
/>
<!-- eslint-enable -->
</template>
<template v-else>
<Username
@ -49,6 +48,7 @@
:on-hover="hoverUser"
:active="user === activeUser"
:user="user"
:include-status-icon="true"
/>
</template>
</div>
@ -56,6 +56,123 @@
</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";

View file

@ -4,13 +4,15 @@
<template v-else>
<Username :user="message.from" />
is away
<i class="away-message">(<ParsedMessage :network="network" :message="message" />)</i>
<i v-if="awayMessage" class="away-message"
>(<ParsedMessage :network="network" :message="message" />)</i
>
</template>
</span>
</template>
<script lang="ts">
import {defineComponent, PropType} from "vue";
import {computed, defineComponent, PropType} from "vue";
import type {ClientNetwork, ClientMessage} from "../../js/types";
import ParsedMessage from "../ParsedMessage.vue";
import Username from "../Username.vue";
@ -31,5 +33,11 @@ export default defineComponent({
required: true,
},
},
setup(props) {
const awayMessage = computed(() => props.message.text.trim());
return {
awayMessage,
};
},
});
</script>

View file

@ -98,6 +98,14 @@
/>
Enable autocomplete
</label>
<label class="opt">
<input
:checked="store.state.settings.statusIcons"
type="checkbox"
name="statusIcons"
/>
Enable status icons
</label>
</div>
<div>
<label class="opt">

View file

@ -0,0 +1,81 @@
<template>
<div
v-if="store.state.settings.statusIcons"
:class="['status', 'tooltipped tooltipped-no-touch', tooltipDirClass]"
:aria-label="ariaLabel"
>
<span :class="{online: online, offline: !online, away: away}" />
</div>
</template>
<style>
.status {
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.online::after,
.away::after,
.offline::after {
content: "";
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.online::after {
background-color: #2ecc40;
}
.offline::after {
background-color: #ff4136;
}
.away::after {
background-color: gray;
}
.status {
z-index: 0;
}
</style>
<script lang="ts">
import {computed, PropType, defineComponent} from "vue";
import {useStore} from "../js/store";
export default defineComponent({
name: "StatusIcon",
props: {
online: Boolean,
away: Boolean,
tooltipDir: String as PropType<"n" | "s" | "e" | "w">,
},
setup(props) {
const store = useStore();
const tooltipDirClass = computed(
() => `tooltipped-${props.tooltipDir ? props.tooltipDir : "w"}`
);
const ariaLabel = computed(() => {
if (props.away) {
return "Away";
} else if (props.online) {
return "Online";
}
return "Offline";
});
return {
tooltipDirClass,
ariaLabel,
store,
};
},
});
</script>

View file

@ -6,8 +6,25 @@
v-on="onHover ? {mouseenter: hover} : {}"
@click.prevent="openContextMenu"
@contextmenu.prevent="openContextMenu"
><slot>{{ mode }}{{ user.nick }}</slot></span
>
><slot v-if="!html">
<StatusIcon
v-if="includeStatusIcon"
:away="!!user.away"
:tooltip-dir="'e'"
:online="true"
/>
{{ mode }}{{ user.nick }}
</slot>
<slot v-else>
<StatusIcon
v-if="includeStatusIcon"
:away="!!user.away"
:tooltip-dir="'e'"
:online="true"
/>
<span class="nick" v-html="html"></span>
</slot>
</span>
</template>
<script lang="ts">
@ -15,7 +32,8 @@ import {computed, defineComponent, PropType} from "vue";
import {UserInMessage} from "../../src/models/msg";
import eventbus from "../js/eventbus";
import colorClass from "../js/helpers/colorClass";
import type {ClientChan, ClientNetwork, ClientUser} from "../js/types";
import type {ClientChan, ClientNetwork} from "../js/types";
import StatusIcon from "./StatusIcon.vue";
type UsernameUser = Partial<UserInMessage> & {
mode?: string;
@ -24,6 +42,9 @@ type UsernameUser = Partial<UserInMessage> & {
export default defineComponent({
name: "Username",
components: {
StatusIcon,
},
props: {
user: {
// TODO: UserInMessage shouldn't be necessary here.
@ -37,6 +58,8 @@ export default defineComponent({
},
channel: {type: Object as PropType<ClientChan>, required: false},
network: {type: Object as PropType<ClientNetwork>, required: false},
includeStatusIcon: Boolean,
html: String,
},
setup(props) {
const mode = computed(() => {

View file

@ -664,10 +664,6 @@ p {
opacity: 1;
}
#viewport.userlist-open #chat .userlist {
display: flex;
}
#sidebar {
display: none;
flex-direction: column;
@ -1221,15 +1217,6 @@ textarea.input {
outline: none;
}
#chat .userlist {
border-left: 1px solid #e7e7e7;
width: 180px;
display: none;
flex-direction: column;
flex-shrink: 0;
touch-action: pan-y;
}
/**
* Toggled via JavaScript
*/
@ -1300,10 +1287,6 @@ textarea.input {
content: "\f107"; /* https://fontawesome.com/icons/angle-down?style=solid */
}
.userlist-open .chat-view[data-type="channel"] .scroll-down {
right: 196px;
}
#chat .messages {
padding: 10px 0;
touch-action: pan-y;
@ -1733,87 +1716,6 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
display: none;
}
#chat .userlist .count {
background: #fafafa;
height: 45px;
flex-shrink: 0;
position: relative;
}
#chat .userlist .search {
color: var(--body-color);
appearance: none;
border: 0;
background: none;
font: inherit;
outline: 0;
padding: 13px;
padding-right: 30px;
width: 100%;
}
#chat .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;
}
#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;
}
#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";
}
#loading {
display: flex;
font-size: 14px;
@ -2693,20 +2595,6 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
position: relative;
}
#chat .userlist {
background-color: var(--window-bg-color);
height: 100%;
position: absolute;
right: 0;
transform: translateX(180px);
transition: transform 0.2s;
z-index: 1;
}
#viewport.userlist-open #chat .userlist {
transform: translateX(0);
}
#chat .header .title {
padding-left: 6px;
}

View file

@ -31,6 +31,9 @@ const defaultConfig = {
coloredNicks: {
default: true,
},
statusIcons: {
default: true,
},
desktopNotifications: {
default: false,
sync: "never",

View file

@ -14,3 +14,31 @@ socket.on("users", function (data) {
channel.channel.usersOutdated = true;
}
});
socket.on("users:online", ({changedChannels, networkId}) => {
for (const network of store.state.networks) {
if (network.uuid === networkId) {
for (const channel of network.channels) {
if (changedChannels.includes(channel.name)) {
channel.isOnline = true;
}
}
break;
}
}
});
socket.on("users:offline", function ({changedChannels, networkId}) {
for (const network of store.state.networks) {
if (network.uuid === networkId) {
for (const channel of network.channels) {
if (changedChannels.includes(channel.name)) {
channel.isOnline = false;
}
}
break;
}
}
});

View file

@ -20,6 +20,7 @@ import ClientManager from "./clientManager";
import {MessageStorage, SearchQuery} from "./plugins/messageStorage/types";
const events = [
"account",
"away",
"cap",
"connection",
@ -45,6 +46,7 @@ const events = [
"topic",
"welcome",
"whois",
"users",
];
type ClientPushSubscription = {

View file

@ -47,6 +47,7 @@ class Chan {
muted!: boolean;
type!: ChanType;
state!: ChanState;
isOnline?: boolean;
// These are added to the channel elsewhere and should not be saved.
userAway!: boolean;
@ -73,6 +74,10 @@ class Chan {
users: new Map(),
muted: false,
});
if (this.type === ChanType.QUERY) {
this.isOnline = false;
}
}
destroy() {

View file

@ -35,6 +35,8 @@ export enum MessageType {
RAW = "raw",
PLUGIN = "plugin",
WALLOPS = "wallops",
ONLINE = "online",
OFFLINE = "offline",
}
class Msg {
@ -59,7 +61,7 @@ class Msg {
command!: string;
invitedYou!: boolean;
gecos!: string;
account!: boolean;
account!: string;
// these are all just for error:
error!: string;

View file

@ -107,8 +107,12 @@ class Network {
CHANTYPES: string[];
PREFIX: Prefix;
NETWORK: string;
MONITOR: number;
};
monitorList!: string[];
toBeMonitored!: string[];
// TODO: this is only available on export
hasSTSPolicy!: boolean;
@ -152,6 +156,8 @@ class Network {
chanCache: [],
ignoreList: [],
keepNick: null,
monitorList: [],
toBeMonitored: [],
});
if (!this.uuid) {
@ -288,7 +294,8 @@ class Network {
this.irc.requestCap([
"znc.in/self-message", // Legacy echo-message for ZNC
"znc.in/playback", // See http://wiki.znc.in/Playback
"znc.in/playback", // See http://wiki.znc.in/Playback,
"draft/extended-monitor", // https://ircv3.net/specs/extensions/extended-monitor
]);
}
@ -521,7 +528,27 @@ class Network {
return status;
}
addChannel(newChan: Chan) {
monitor(this: NetworkWithIrcFramework, target: string) {
this.irc.addMonitor(target);
if (this.monitorList.length < this.serverOptions.MONITOR) {
this.monitorList.push(target);
} else {
// TODO: ISON fallback?
this.toBeMonitored.push(target);
}
}
removeMonitor(this: NetworkWithIrcFramework, target: string) {
this.irc.removeMonitor(target);
this.monitorList = this.monitorList.filter((monitored) => monitored !== target);
if (this.toBeMonitored.length > 0) {
this.monitor(this.toBeMonitored.shift() as string);
}
}
addChannel(this: NetworkWithIrcFramework, newChan: Chan) {
let index = this.channels.length; // Default to putting as the last item in the array
// Don't sort special channels in amongst channels/users.
@ -544,6 +571,13 @@ class Network {
}
this.channels.splice(index, 0, newChan);
if (newChan.type === ChanType.QUERY) {
if (!this.monitorList.includes(newChan.name)) {
this.monitor(newChan.name);
}
}
return index;
}

View file

@ -7,6 +7,7 @@ class User {
mode!: string;
away!: string;
nick!: string;
account!: string;
lastMessage!: number;
constructor(attr: Partial<User>, prefix?: Prefix) {
@ -14,6 +15,7 @@ class User {
modes: [],
away: "",
nick: "",
account: "",
lastMessage: 0,
});
@ -37,6 +39,8 @@ class User {
nick: this.nick,
modes: this.modes,
lastMessage: this.lastMessage,
away: this.away,
account: this.account,
};
}
}

View file

@ -0,0 +1,24 @@
import {IrcEventHandler} from "../../client";
import Msg, {MessageType} from "../../models/msg";
export default <IrcEventHandler>function (irc, network) {
const client = this;
irc.on("account", function (data) {
network.channels.forEach((chan) => {
const user = chan.findUser(data.nick);
if (typeof user === "undefined") {
return;
}
user.account = data.account ? data.account : "";
chan.setUser(user);
client.emit("users", {
chan: chan.id,
});
});
});
};

View file

@ -63,6 +63,11 @@ export default <IrcEventHandler>function (irc, network) {
}
user.away = away;
chan.setUser(user);
client.emit("users", {
chan: chan.id,
});
break;
}

View file

@ -54,6 +54,10 @@ export default <IrcEventHandler>function (irc, network) {
network.channels.forEach((chan) => {
if (chan.type !== ChanType.CHANNEL) {
if (chan.type === ChanType.QUERY) {
network.monitor(chan.name);
}
return;
}
@ -204,6 +208,7 @@ export default <IrcEventHandler>function (irc, network) {
}
network.serverOptions.NETWORK = data.options.NETWORK;
network.serverOptions.MONITOR = data.options.MONITOR;
client.emit("network:options", {
network: network.uuid,

View file

@ -1,13 +1,32 @@
import Msg, {MessageType} from "../../models/msg";
import Chan, {ChanState, ChanType} from "../../models/chan";
import User from "../../models/user";
import type {IrcEventHandler} from "../../client";
import {ChanState} from "../../models/chan";
export default <IrcEventHandler>function (irc, network) {
const client = this;
irc.on("join", function (data) {
const performWhoOnChannel = (chan: Chan) => {
if (chan.type !== ChanType.CHANNEL) {
return;
}
irc.who(chan.name, (whoData) => {
for (const user of whoData.users) {
chan.setUser(
new User({
nick: user.nick,
away: user.away,
account: user.account,
})
);
}
});
};
let chan = network.getChannel(data.channel);
const isSelf = data.nick === irc.user.nick;
if (typeof chan === "undefined") {
chan = client.createChannel({
@ -26,7 +45,7 @@ export default <IrcEventHandler>function (irc, network) {
// Request channels' modes
network.irc.raw("MODE", chan.name);
} else if (data.nick === irc.user.nick) {
} else if (isSelf) {
chan.state = ChanState.JOINED;
client.emit("channel:state", {
@ -35,7 +54,7 @@ export default <IrcEventHandler>function (irc, network) {
});
}
const user = new User({nick: data.nick});
const user = new User({nick: data.nick, account: data.account});
const msg = new Msg({
time: data.time,
from: user,
@ -43,13 +62,18 @@ export default <IrcEventHandler>function (irc, network) {
gecos: data.gecos,
account: data.account,
type: MessageType.JOIN,
self: data.nick === irc.user.nick,
self: isSelf,
});
chan.pushMessage(client, msg);
chan.setUser(new User({nick: data.nick}));
chan.setUser(user);
client.emit("users", {
chan: chan.id,
});
// If we join a channel, we perform a WHO to get all the users away statuses
if (isSelf) {
performWhoOnChannel(chan);
}
});
};

View file

@ -0,0 +1,40 @@
"use strict";
import type {IrcEventHandler} from "../../client";
import {ChanType} from "../../models/chan";
export default <IrcEventHandler>function (irc, network) {
const client = this;
irc.on("users online", function (data) {
const changedChannels: string[] = [];
for (const nick of data.nicks) {
for (const channel of network.channels) {
if (channel.type === ChanType.QUERY && channel.name === nick) {
channel.isOnline = true;
changedChannels.push(channel.name);
continue;
}
}
}
client.emit("users:online", {changedChannels, networkId: network.uuid});
});
irc.on("users offline", function (data) {
const changedChannels: string[] = [];
for (const nick of data.nicks) {
for (const channel of network.channels) {
if (channel.type === ChanType.QUERY && channel.name === nick) {
channel.isOnline = false;
changedChannels.push(channel.name);
continue;
}
}
}
client.emit("users:offline", {changedChannels, networkId: network.uuid});
});
};

View file

@ -37,8 +37,7 @@ declare module "irc-framework" {
type: "privmsg" | "action"; // TODO
}
export interface JoinEventArgs {
// todo: is that wrong?
account: boolean;
account: string;
channel: string;
gecos: string;
hostname: string;
@ -235,6 +234,10 @@ declare module "irc-framework" {
matchAction(match_regex: string, cb: (event: Event) => any): void;
addMonitor(target: string): void;
removeMonitor(target: string): void;
stringToBlocks(str: string, block_size?: number): string[];
on(eventType: string | symbol, cb: (event: any) => void): this;

View file

@ -120,6 +120,10 @@ interface ServerToClientEvents {
network: string;
chan: InitClientChan;
}) => void;
"users:online": (args: {changedChannels: string[]; networkId: string}) => void;
"users:offline": (args: {changedChannels: string[]; networkId: string}) => void;
}
interface ClientToServerEvents {