Favorites network

This commit is contained in:
Max Leiter 2022-04-30 23:01:22 -07:00
parent 9dbb6e5e19
commit f2a8d5aacc
No known key found for this signature in database
GPG key ID: A3512F2F2F17EBDA
23 changed files with 553 additions and 62 deletions

View file

@ -1,6 +1,6 @@
<template>
<ChannelWrapper ref="wrapper" v-bind="$props">
<span class="name">{{ channel.name }}</span>
<span class="name">{{ name() }}</span>
<span
v-if="channel.unread"
:class="{highlight: channel.highlight && !channel.muted}"
@ -51,6 +51,9 @@ export default {
close() {
this.$root.closeChannel(this.channel);
},
name() {
return this.channel.displayName ? this.channel.displayName : this.channel.name;
},
},
};
</script>

View file

@ -0,0 +1,27 @@
.collapse-network {
width: 40px;
opacity: 0.4;
padding-left: 11px;
transition: opacity 0.2s;
flex-shrink: 0;
}
.collapse-network-icon {
display: block;
width: 20px;
height: 20px;
transition: transform 0.2s;
}
.network.collapsed .collapse-network-icon {
transform: rotate(-90deg);
}
.collapse-network-icon::before {
content: "\f0d7"; /* http://fontawesome.io/icon/caret-down/ */
color: #fff;
}
.collapse-network:hover {
opacity: 1;
}

View file

@ -0,0 +1,36 @@
<template>
<button
v-if="favorites.length > 0"
:aria-label="getExpandLabel()"
:aria-expanded="isCollapsed"
class="collapse-network"
@click.stop="onCollapseClick"
>
<span class="collapse-network-icon" />
</button>
<span v-else class="collapse-network" />
</template>
<style scoped>
@import "./CollapseButton.css";
</style>
<script>
export default {
name: "CollapseFavoritesButton",
props: {
onCollapseClick: Function,
},
data() {
return {
favorites: this.$store.state.favoriteChannels,
isCollapsed: !this.$store.state.favoritesOpen,
};
},
methods: {
getExpandLabel() {
return this.isCollapsed ? "Expand" : "Collapse";
},
},
};
</script>

View file

@ -0,0 +1,32 @@
<template>
<button
v-if="network.channels.length > 1"
:aria-controls="'network-' + network.uuid"
:aria-label="getExpandLabel(network)"
:aria-expanded="!network.isCollapsed"
class="collapse-network"
@click.stop="onCollapseClick"
>
<span class="collapse-network-icon" />
</button>
<span v-else class="collapse-network" />
</template>
<style scoped>
@import "./CollapseButton.css";
</style>
<script>
export default {
name: "CollapseNetworkButton",
props: {
network: Object,
onCollapseClick: Function,
},
methods: {
getExpandLabel(network) {
return network.isCollapsed ? "Expand" : "Collapse";
},
},
};
</script>

View file

@ -43,6 +43,7 @@ import {
generateUserContextMenu,
generateChannelContextMenu,
generateInlineChannelContextMenu,
generateFavoritesContextMenu,
} from "../js/helpers/contextMenu.js";
import eventbus from "../js/eventbus";
@ -70,6 +71,7 @@ export default {
eventbus.on("contextmenu:user", this.openUserContextMenu);
eventbus.on("contextmenu:channel", this.openChannelContextMenu);
eventbus.on("contextmenu:inline-channel", this.openInlineChannelContextMenu);
eventbus.on("contextmenu:favorites", this.openFavoritesContextMenu);
},
destroyed() {
eventbus.off("escapekey", this.close);
@ -77,6 +79,7 @@ export default {
eventbus.off("contextmenu:user", this.openUserContextMenu);
eventbus.off("contextmenu:channel", this.openChannelContextMenu);
eventbus.off("contextmenu:inline-channel", this.openInlineChannelContextMenu);
eventbus.off("contextmenu:favorites", this.openFavoritesContextMenu);
this.close();
},
@ -119,6 +122,10 @@ export default {
);
this.open(data.event, items);
},
openFavoritesContextMenu(data) {
const items = generateFavoritesContextMenu();
this.open(data.event, items);
},
open(event, items) {
event.preventDefault();

View file

@ -0,0 +1,75 @@
<template>
<div class="favorites">
<div class="channel-list-item" data-type="lobby" @contextmenu.prevent="openContextMenu">
<div class="lobby-wrap">
<CollapseFavoritesButton :on-collapse-click="onCollapseClick" />
<span title="Favorites" class="name">Favorites</span>
<span v-if="unreadCount > 0" class="badge">{{ unreadCount }}</span>
</div>
</div>
<div v-for="channel in $store.state.favoriteChannels" :key="channel.id">
<Channel
:channel="channel"
:network="network"
:is-filtering="false"
:active="
$store.state.activeChannel && channel === $store.state.activeChannel.channel
"
/>
</div>
</div>
</template>
<style scoped>
.lobby-wrap {
display: flex;
/* margin-left: 40px; */
}
</style>
<script>
import eventbus from "../js/eventbus";
import roundBadgeNumber from "../js/helpers/roundBadgeNumber";
import Channel from "./Channel.vue";
import CollapseFavoritesButton from "./CollapseFavoritesButton.vue";
export default {
name: "Favorites",
components: {
Channel,
CollapseFavoritesButton,
},
props: {
channels: Array,
},
computed: {
network() {
return {
isCollapsed: !this.$store.state.favoritesOpen,
status: {
connected: true,
secure: true,
},
};
},
},
methods: {
onCollapseClick() {
this.$store.commit("toggleFavorites");
},
openContextMenu(event) {
eventbus.emit("contextmenu:favorites", {
event: event,
channel: this.channel,
});
},
unreadCount() {
const unread = this.channels.reduce((acc, channel) => {
return acc + channel.unread || 0;
}, 0);
return roundBadgeNumber(unread);
},
},
};
</script>

View file

@ -69,6 +69,19 @@
@choose="onDraggableChoose"
@unchoose="onDraggableUnchoose"
>
<div
v-if="$store.state.favoriteChannels.length"
id="favorites"
aria-label="Favorite channels"
class="network"
:class="{
collapsed: !$store.state.favoritesOpen,
}"
role="region"
aria-live="polite"
>
<Favorites :channels="$store.state.favoriteChannels" />
</div>
<div
v-for="network in $store.state.networks"
:id="'network-' + network.uuid"
@ -101,7 +114,6 @@
:channel="network.channels[0]"
@toggle-join-channel="network.isJoinChannelShown = !network.isJoinChannelShown"
/>
<Draggable
draggable=".channel-list-item"
ghost-class="ui-sortable-ghost"
@ -118,7 +130,7 @@
>
<template v-for="(channel, index) in network.channels">
<Channel
v-if="index > 0"
v-if="index > 0 && !channel.favorite"
:key="channel.id"
:channel="channel"
:network="network"
@ -200,6 +212,7 @@ import Mousetrap from "mousetrap";
import Draggable from "vuedraggable";
import {filter as fuzzyFilter} from "fuzzy";
import NetworkLobby from "./NetworkLobby.vue";
import Favorites from "./Favorites.vue";
import Channel from "./Channel.vue";
import JoinChannel from "./JoinChannel.vue";
@ -216,6 +229,7 @@ export default {
NetworkLobby,
Channel,
Draggable,
Favorites,
},
data() {
return {
@ -263,6 +277,8 @@ export default {
Mousetrap.bind("alt+shift+right", this.expandNetwork);
Mousetrap.bind("alt+shift+left", this.collapseNetwork);
Mousetrap.bind("alt+j", this.toggleSearch);
console.log(this.$store.state.favoriteChannels[0]);
},
beforeDestroy() {
Mousetrap.unbind("alt+shift+right", this.expandNetwork);

View file

@ -1,16 +1,6 @@
<template>
<ChannelWrapper v-bind="$props" :channel="channel">
<button
v-if="network.channels.length > 1"
:aria-controls="'network-' + network.uuid"
:aria-label="getExpandLabel(network)"
:aria-expanded="!network.isCollapsed"
class="collapse-network"
@click.stop="onCollapseClick"
>
<span class="collapse-network-icon" />
</button>
<span v-else class="collapse-network" />
<CollapseNetworkButton :network="network" :on-collapse-click="onCollapseClick" />
<div class="lobby-wrap">
<span :title="channel.name" class="name">{{ channel.name }}</span>
<span
@ -49,11 +39,13 @@
import collapseNetwork from "../js/helpers/collapseNetwork";
import roundBadgeNumber from "../js/helpers/roundBadgeNumber";
import ChannelWrapper from "./ChannelWrapper.vue";
import CollapseNetworkButton from "./CollapseNetworkButton.vue";
export default {
name: "Channel",
components: {
ChannelWrapper,
CollapseNetworkButton,
},
props: {
network: Object,
@ -76,9 +68,6 @@ export default {
onCollapseClick() {
collapseNetwork(this.network, !this.network.isCollapsed);
},
getExpandLabel(network) {
return network.isCollapsed ? "Expand" : "Collapse";
},
},
};
</script>

View file

@ -369,6 +369,7 @@ p {
.context-menu-action-revoke-mode::before { content: "\f068"; /* http://fontawesome.io/icon/minus/ */ }
.context-menu-network::before { content: "\f233"; /* https://fontawesome.com/icons/server?style=solid */ }
.context-menu-edit::before { content: "\f303"; /* https://fontawesome.com/icons/pencil-alt?style=solid */ }
.context-menu-clear-favorites::before,
.context-menu-clear-history::before { content: "\f1f8"; /* https://fontawesome.com/icons/trash?style=solid */ }
.context-menu-mute::before { content: "\f6a9"; /* https://fontawesome.com/v5.15/icons/volume-mute?style=solid */ }
@ -919,34 +920,6 @@ background on hover (unless active) */
transform: rotate(45deg) translateZ(0);
}
#sidebar .network .collapse-network {
width: 40px;
opacity: 0.4;
padding-left: 11px;
transition: opacity 0.2s;
flex-shrink: 0;
}
#sidebar .network .collapse-network-icon {
display: block;
width: 20px;
height: 20px;
transition: transform 0.2s;
}
#sidebar .network.collapsed .collapse-network-icon {
transform: rotate(-90deg);
}
#sidebar .network .collapse-network-icon::before {
content: "\f0d7"; /* http://fontawesome.io/icon/caret-down/ */
color: #fff;
}
#sidebar .collapse-network:hover {
opacity: 1;
}
#footer {
height: 45px;
font-size: 14px;

View file

@ -0,0 +1,33 @@
"use strict";
import socket from "../socket";
import store from "../store";
function input(args) {
if (args.length === 0) {
const {channel} = store.state.activeChannel;
socket.emit("input", {
target: channel.id,
text: `/favorite ${channel.name}`,
});
} else {
for (const arg of args) {
for (const network of store.state.networks) {
const channel = network.channels.find((c) => c.name === arg);
if (!channel) {
continue;
}
socket.emit("input", {
target: channel.id,
text: `/favorite ${channel.name}`,
});
}
}
}
return true;
}
export default {input};

View file

@ -88,6 +88,21 @@ export function generateChannelContextMenu($root, channel, network) {
}),
},
];
// Add menu items for all except lobbies
} else {
// Add favorites item
items.push({
label: channel.favorite ? "Remove from favorites" : "Add to favorites",
type: "item",
class: "favorite",
action() {
if (channel.favorite) {
socket.emit("favorites:remove", channel.id);
} else {
socket.emit("favorites:add", channel.id);
}
},
});
}
// Add menu items for channels
@ -208,6 +223,21 @@ export function generateChannelContextMenu($root, channel, network) {
return items;
}
export function generateFavoritesContextMenu() {
const items = [];
items.push({
label: "Clear favorites",
type: "item",
class: "clear-favorites",
action() {
socket.emit("favorites:clear");
},
});
return items;
}
export function generateInlineChannelContextMenu($root, chan, network) {
const join = () => {
const channel = network.channels.find((c) => c.name === chan);

View file

@ -3,7 +3,12 @@
import store from "../store";
export default (network, channel) => {
if (!network.isCollapsed || channel.highlight || channel.type === "lobby") {
if (
!network.isCollapsed ||
channel.highlight ||
channel.type === "lobby" ||
(channel.favorite === true && store.state.favoritesOpen)
) {
return false;
}

View file

@ -0,0 +1,10 @@
"use strict";
import socket from "../socket";
import store from "../store";
socket.on("favorites", function (data) {
console.log("favorites", data);
store.commit("favoriteChannels", data.favoriteChannels);
});

View file

@ -27,3 +27,4 @@ import "./history_clear";
import "./mentions";
import "./search";
import "./mute_changed";
import "./favorites";

View file

@ -11,6 +11,7 @@ socket.on("init", function (data) {
store.commit("networks", mergeNetworkData(data.networks));
store.commit("isConnected", true);
store.commit("currentUserVisibleError", null);
store.commit("favoriteChannels", data.favoriteChannels);
if (data.token) {
storage.set("token", data.token);

View file

@ -28,6 +28,7 @@ const store = new Vuex.Store({
isAutoCompleting: false,
isConnected: false,
networks: [],
favoriteChannels: [],
mentions: [],
hasServiceWorker: false,
pushNotificationState: "unsupported",
@ -43,6 +44,7 @@ const store = new Vuex.Store({
messageSearchResults: null,
messageSearchInProgress: false,
searchEnabled: false,
favoritesOpen: storage.get("thelounge.state.favorites") !== "false",
},
mutations: {
appLoaded(state) {
@ -129,10 +131,59 @@ const store = new Vuex.Store({
state.messageSearchResults = value;
},
favoriteChannels(state, payload) {
console.log("payload", payload);
state.favoriteChannels.forEach((channel) => {
channel.favorite = false;
channel.displayName = "";
});
store.favoriteChannels = [];
// Channels can have the same name across networks, so we need to track and distinguish duplicates.
// We use a map so we can go back and update the channel that the name is a duplicate of.
// If they have a the same names on two different networks that have the same name,
// that's on them. I'm not paid to do this.
const names = new Map(); // Map of name --> { channelId, networkuuId }
state.favoriteChannels = payload.map(({channelId, networkUuid}) => {
const netChan = this.getters.findChannelOnNetworkById(networkUuid, channelId);
netChan.channel.favorite = true;
if (names.has(netChan.channel.name)) {
const dupe = names.get(netChan.channel.name);
if (dupe) {
const otherNetChan = this.getters.findChannelOnNetworkById(
dupe.networkId,
dupe.channelId
);
netChan.channel.displayName =
netChan.channel.name + `(${netChan.network.name})`;
otherNetChan.channel.displayName =
otherNetChan.channel.name + ` (${otherNetChan.network.name})`;
}
} else {
names.set(netChan.channel.name, {
channelId: netChan.channel.id,
networkId: netChan.network.uuid,
});
}
return netChan.channel;
});
},
toggleFavorites(state) {
state.favoritesOpen = !state.favoritesOpen;
},
},
actions: {
partChannel({commit, state}, netChan) {
const mentions = state.mentions.filter((msg) => !(msg.chanId === netChan.channel.id));
const favorites = state.favoriteChannels.filter((fav) => fav.id !== netChan.channel.id);
commit("favoriteChannels", favorites);
commit("mentions", mentions);
},
},
@ -156,6 +207,21 @@ const store = new Vuex.Store({
return null;
},
findChannelOnNetworkById: (state) => (networkUuid, channelId) => {
for (const network of state.networks) {
if (network.uuid !== networkUuid) {
continue;
}
for (const channel of network.channels) {
if (channel.id === channelId) {
return {network, channel};
}
}
}
return null;
},
findChannel: (state) => (id) => {
for (const network of state.networks) {
for (const channel of network.channels) {

View file

@ -89,6 +89,13 @@ store.watch(
}
);
store.watch(
(state) => state.favoritesOpen,
(favoritesOpen) => {
storage.set("thelounge.state.favorites", favoritesOpen);
}
);
store.watch(
(_, getters) => getters.title,
(title) => {

View file

@ -59,6 +59,7 @@ function Client(manager, name, config = {}) {
idMsg: 1,
name: name,
networks: [],
favoriteChannels: [],
mentions: [],
manager: manager,
messageStorage: [],
@ -119,7 +120,18 @@ function Client(manager, name, config = {}) {
}
});
(client.config.networks || []).forEach((network) => client.connect(network, true));
(client.config.networks || []).forEach((network) => {
client.connect(network, true);
for (const chan of network.channels) {
if (chan.favorite) {
// third argument is whether to save or not;
// we don't need to here as the config is loaded from the filesystem
console.log(network.uuid, chan.id);
client.addToFavorites(network.uuid, chan.id);
}
}
});
// Networks are stored directly in the client object
// We don't need to keep it in the config object
@ -654,6 +666,7 @@ Client.prototype.part = function (network, chan) {
const client = this;
network.channels = _.without(network.channels, chan);
client.mentions = client.mentions.filter((msg) => !(msg.chanId === chan.id));
client.favoriteChannels = client.favoriteChannels.filter((fav) => fav.id !== chan.id);
chan.destroy();
client.save();
client.emit("part", {
@ -769,3 +782,81 @@ Client.prototype.save = _.debounce(
5000,
{maxWait: 20000}
);
Client.prototype.addToFavorites = function (networkUuid, chanId, shouldSave = true) {
const client = this;
const favorites = client.favoriteChannels;
const isFavorited = favorites.find(({channelId}) => channelId === chanId);
if (!isFavorited) {
favorites.push({
channelId: chanId,
networkUuid: networkUuid,
});
if (shouldSave) {
client.save();
}
}
client.emitToAttachedClients("favorites", {
favoriteChannels: client.favoriteChannels,
});
const netChan = client.find(chanId);
if (netChan.chan) {
netChan.chan.favorite = true;
}
};
Client.prototype.removeFromFavorites = function (chanId) {
const client = this;
const favorites = client.favoriteChannels;
const isFavorited = favorites.find(({channelId}) => channelId === chanId);
if (isFavorited) {
favorites.splice(favorites.indexOf(isFavorited), 1);
client.save();
}
client.emitToAttachedClients("favorites", {
favoriteChannels: client.favoriteChannels,
});
const netChan = client.find(chanId);
if (netChan.chan) {
netChan.chan.favorite = false;
}
};
Client.prototype.clearFavorites = function () {
const client = this;
for (const favorite of client.favoriteChannels) {
const netChan = client.find(favorite.channelId);
if (netChan.chan) {
netChan.chan.favorite = false;
}
}
client.favoriteChannels = [];
client.save();
client.emitToAttachedClients("favorites", {
favoriteChannels: client.favoriteChannels,
});
};
Client.prototype.emitToAttachedClients = function (event, data) {
const client = this;
for (const socketId in client.attachedClients) {
const socket = client.manager.sockets.in(socketId);
if (socket) {
socket.emit(event, data);
}
}
};

View file

@ -42,6 +42,7 @@ function Chan(attr) {
highlight: 0,
users: new Map(),
muted: false,
favorite: false,
});
}
@ -299,6 +300,18 @@ Chan.prototype.setMuteStatus = function (muted) {
this.muted = !!muted;
};
Chan.prototype.export = function () {
const keys = ["name", "muted", "favorite"];
if (this.type === Chan.Type.CHANNEL) {
keys.push("key");
} else if (this.type === Chan.Type.QUERY) {
keys.push("type");
}
return _.pick(this, keys);
};
function requestZncPlayback(channel, network, from) {
network.irc.raw("ZNC", "*playback", "PLAY", channel.name, from.toString());
}

View file

@ -533,15 +533,7 @@ Network.prototype.export = function () {
return channel.type === Chan.Type.CHANNEL || channel.type === Chan.Type.QUERY;
})
.map(function (chan) {
const keys = ["name", "muted"];
if (chan.type === Chan.Type.CHANNEL) {
keys.push("key");
} else if (chan.type === Chan.Type.QUERY) {
keys.push("type");
}
return _.pick(chan, keys);
return chan.export();
});
return network;

View file

@ -0,0 +1,52 @@
"use strict";
const Msg = require("../../models/msg");
const User = require("../../models/user");
exports.commands = ["favorite"];
exports.allowDisconnected = true;
exports.input = function (network, chan, cmd, args) {
const client = this;
if (args.length === 0) {
chan.pushMessage(
client,
new Msg({
type: Msg.Type.ERROR,
text: `Usage: /favorite [channel-or-conversation-name]`,
})
);
return;
} else if (args.length === 1) {
const channel = network.channels.find((c) => c.name === args[0]);
if (!channel) {
chan.pushMessage(
client,
new Msg({
type: Msg.Type.ERROR,
text: `Channel or conversation ${args[0]} not found.`,
})
);
return;
}
this.addToFavorites(network.uuid, channel.id);
chan.pushMessage(
client,
new Msg({
// type: Msg.Type.ACTION,
text: `Favorited ${channel.name}`,
from: new User({
nick: network.irc.user.nick,
}),
self: true,
})
);
}
this.save();
};

View file

@ -36,6 +36,7 @@ const userInputs = [
"topic",
"whois",
"mute",
"favorite",
].reduce(function (plugins, name) {
const plugin = require(`./${name}`);
plugin.commands.forEach((command) => plugins.set(command, plugin));

View file

@ -684,12 +684,10 @@ function initializeClient(socket, client, token, lastMessage, openChannel) {
}
}
for (const attachedClient of Object.keys(client.attachedClients)) {
manager.sockets.in(attachedClient).emit("mute:changed", {
target,
status: setMutedTo,
});
}
client.emitToAttachedClients("mute:changed", {
target,
status: setMutedTo,
});
client.save();
});
@ -726,6 +724,38 @@ function initializeClient(socket, client, token, lastMessage, openChannel) {
}
});
socket.on("favorites:add", (channelId) => {
if (!channelId) {
return;
}
const {network, chan} = client.find(channelId);
if (!network || !chan) {
return;
}
client.addToFavorites(network.uuid, chan.id);
});
socket.on("favorites:remove", (channelId) => {
if (!channelId) {
return;
}
const {network, chan} = client.find(channelId);
if (!network || !chan) {
return;
}
client.removeFromFavorites(chan.id);
});
socket.on("favorites:clear", () => {
client.clearFavorites();
});
socket.join(client.id);
const sendInitEvent = (tokenToSend) => {
@ -735,6 +765,7 @@ function initializeClient(socket, client, token, lastMessage, openChannel) {
network.getFilteredClone(openChannel, lastMessage)
),
token: tokenToSend,
favoriteChannels: client.favoriteChannels,
});
socket.emit("commands", inputs.getCommands());
};