FEATURE: Add keyboard shortcuts for jumping to unread channels (#29734)
This commit is contained in:
parent
f700a72c8f
commit
69d9868c7f
|
@ -28,7 +28,11 @@ function buildHTML(keys1, keys2, keysDelimiter, shortcutsDelimiter) {
|
|||
const allKeys = [keys1, keys2]
|
||||
.reject((keys) => keys.length === 0)
|
||||
.map((keys) => keys.map((k) => `<kbd>${k}</kbd>`).join(keysDelimiter))
|
||||
.map((keys) => (shortcutsDelimiter !== "space" ? wrapInSpan(keys) : keys));
|
||||
.map((keys) =>
|
||||
shortcutsDelimiter !== "space" && shortcutsDelimiter !== "newline"
|
||||
? wrapInSpan(keys, shortcutsDelimiter)
|
||||
: keys
|
||||
);
|
||||
|
||||
const [shortcut1, shortcut2] = allKeys;
|
||||
|
||||
|
@ -40,13 +44,22 @@ function buildHTML(keys1, keys2, keysDelimiter, shortcutsDelimiter) {
|
|||
return I18n.t(`${KEY}.shortcut_delimiter_slash`, { shortcut1, shortcut2 });
|
||||
} else if (shortcutsDelimiter === "space") {
|
||||
return wrapInSpan(
|
||||
I18n.t(`${KEY}.shortcut_delimiter_space`, { shortcut1, shortcut2 })
|
||||
I18n.t(`${KEY}.shortcut_delimiter_space`, { shortcut1, shortcut2 }),
|
||||
shortcutsDelimiter
|
||||
);
|
||||
} else if (shortcutsDelimiter === "newline") {
|
||||
return wrapInSpan(
|
||||
I18n.t(`${KEY}.shortcut_delimiter_newline`, {
|
||||
shortcut1,
|
||||
shortcut2,
|
||||
}),
|
||||
shortcutsDelimiter
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function wrapInSpan(shortcut) {
|
||||
return `<span dir="ltr">${shortcut}</span>`;
|
||||
function wrapInSpan(shortcut, delimiter) {
|
||||
return `<span class="delimiter-${delimiter}" dir="ltr">${shortcut}</span>`;
|
||||
}
|
||||
|
||||
function buildShortcut(
|
||||
|
|
|
@ -105,6 +105,11 @@
|
|||
margin-left: auto;
|
||||
}
|
||||
|
||||
.delimiter-newline {
|
||||
display: revert;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
kbd {
|
||||
font-family: var(--font-family);
|
||||
font-weight: bold;
|
||||
|
|
|
@ -4479,6 +4479,7 @@ en:
|
|||
shortcut_delimiter_or: "%{shortcut1} or %{shortcut2}"
|
||||
shortcut_delimiter_slash: "%{shortcut1}/%{shortcut2}"
|
||||
shortcut_delimiter_space: "%{shortcut1} %{shortcut2}"
|
||||
shortcut_delimiter_newline: "%{shortcut1}<br>%{shortcut2}"
|
||||
title: "Keyboard Shortcuts"
|
||||
short_title: "Shortcuts"
|
||||
jump_to:
|
||||
|
|
|
@ -40,6 +40,18 @@ export default {
|
|||
chatService.switchChannelUpOrDown("down");
|
||||
};
|
||||
|
||||
const handleMoveUpUnreadShortcut = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
chatService.switchChannelUpOrDown("up", true);
|
||||
};
|
||||
|
||||
const handleMoveDownUnreadShortcut = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
chatService.switchChannelUpOrDown("down", true);
|
||||
};
|
||||
|
||||
const isChatComposer = (el) =>
|
||||
el.classList.contains("chat-composer__input");
|
||||
const isInputSelection = (el) => {
|
||||
|
@ -156,6 +168,25 @@ export default {
|
|||
api.addKeyboardShortcut("alt+down", handleMoveDownShortcut, {
|
||||
global: true,
|
||||
});
|
||||
|
||||
api.addKeyboardShortcut("alt+shift+up", handleMoveUpUnreadShortcut, {
|
||||
global: true,
|
||||
help: {
|
||||
category: "chat",
|
||||
name: "chat.keyboard_shortcuts.switch__unread_channel_arrows",
|
||||
definition: {
|
||||
keys1: ["alt", "shift", "↑"],
|
||||
keys2: ["alt", "shift", "↓"],
|
||||
keysDelimiter: "plus",
|
||||
shortcutsDelimiter: "newline",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
api.addKeyboardShortcut("alt+shift+down", handleMoveDownUnreadShortcut, {
|
||||
global: true,
|
||||
});
|
||||
|
||||
api.addKeyboardShortcut(
|
||||
`${PLATFORM_KEY_MODIFIER}+b`,
|
||||
(event) => modifyComposerSelection(event, "bold"),
|
||||
|
|
|
@ -131,6 +131,18 @@ export default class ChatChannelsManager extends Service {
|
|||
.sort((a, b) => a?.slug?.localeCompare?.(b?.slug));
|
||||
}
|
||||
|
||||
@cached
|
||||
get publicMessageChannelsWithActivity() {
|
||||
return this.publicMessageChannels.filter(
|
||||
(channel) =>
|
||||
channel.tracking.unreadCount +
|
||||
channel.tracking.mentionCount +
|
||||
channel.tracking.watchedThreadsUnreadCount +
|
||||
channel.threadsManager.unreadThreadCount >
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
get publicMessageChannelsByActivity() {
|
||||
return this.#sortChannelsByActivity(this.publicMessageChannels);
|
||||
}
|
||||
|
@ -145,6 +157,18 @@ export default class ChatChannelsManager extends Service {
|
|||
);
|
||||
}
|
||||
|
||||
@cached
|
||||
get directMessageChannelsWithActivity() {
|
||||
return this.directMessageChannels.filter(
|
||||
(channel) =>
|
||||
channel.tracking.unreadCount +
|
||||
channel.tracking.mentionCount +
|
||||
channel.tracking.watchedThreadsUnreadCount +
|
||||
channel.threadsManager.unreadThreadCount >
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
get truncatedDirectMessageChannels() {
|
||||
return this.directMessageChannels.slice(0, DIRECT_MESSAGE_CHANNELS_LIMIT);
|
||||
}
|
||||
|
|
|
@ -278,19 +278,31 @@ export default class Chat extends Service {
|
|||
: 0;
|
||||
}
|
||||
|
||||
switchChannelUpOrDown(direction) {
|
||||
switchChannelUpOrDown(direction, unreadOnly = false) {
|
||||
const { activeChannel } = this;
|
||||
if (!activeChannel) {
|
||||
return; // Chat isn't open. Return and do nothing!
|
||||
}
|
||||
|
||||
let publicChannels, directChannels;
|
||||
|
||||
if (unreadOnly) {
|
||||
publicChannels =
|
||||
this.chatChannelsManager.publicMessageChannelsWithActivity;
|
||||
directChannels =
|
||||
this.chatChannelsManager.directMessageChannelsWithActivity;
|
||||
} else {
|
||||
publicChannels = this.chatChannelsManager.publicMessageChannels;
|
||||
directChannels = this.chatChannelsManager.directMessageChannels;
|
||||
}
|
||||
|
||||
let currentList, otherList;
|
||||
if (activeChannel.isDirectMessageChannel) {
|
||||
currentList = this.chatChannelsManager.truncatedDirectMessageChannels;
|
||||
otherList = this.chatChannelsManager.publicMessageChannels;
|
||||
currentList = directChannels;
|
||||
otherList = publicChannels;
|
||||
} else {
|
||||
currentList = this.chatChannelsManager.publicMessageChannels;
|
||||
otherList = this.chatChannelsManager.truncatedDirectMessageChannels;
|
||||
currentList = publicChannels;
|
||||
otherList = directChannels;
|
||||
}
|
||||
|
||||
const directionUp = direction === "up";
|
||||
|
|
|
@ -725,6 +725,7 @@ en:
|
|||
title: "Chat"
|
||||
keyboard_shortcuts:
|
||||
switch_channel_arrows: "%{shortcut} Switch channel"
|
||||
switch__unread_channel_arrows: "%{shortcut} Switch unread channel"
|
||||
open_quick_channel_selector: "%{shortcut} Open quick channel selector"
|
||||
open_insert_link_modal: "%{shortcut} Insert hyperlink (composer only)"
|
||||
composer_bold: "%{shortcut} Bold (composer only)"
|
||||
|
|
|
@ -11,7 +11,7 @@ RSpec.describe "Shortcuts | sidebar", type: :system do
|
|||
sign_in(current_user)
|
||||
end
|
||||
|
||||
context "when using Up/Down arrows" do
|
||||
context "when using Alt+Up/Down arrows" do
|
||||
fab!(:channel_1) { Fabricate(:chat_channel) }
|
||||
fab!(:dm_channel_1) { Fabricate(:direct_message_channel, users: [current_user]) }
|
||||
|
||||
|
@ -47,4 +47,88 @@ RSpec.describe "Shortcuts | sidebar", type: :system do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "when using Alt+Shift+Up/Down arrows" do
|
||||
fab!(:channel_1) { Fabricate(:chat_channel) }
|
||||
fab!(:channel_2) { Fabricate(:chat_channel) }
|
||||
fab!(:dm_channel_1) { Fabricate(:direct_message_channel, users: [current_user]) }
|
||||
fab!(:dm_channel_2) { Fabricate(:direct_message_channel, users: [current_user]) }
|
||||
|
||||
before do
|
||||
channel_1.add(current_user)
|
||||
channel_2.add(current_user)
|
||||
end
|
||||
|
||||
context "when on homepage" do
|
||||
it "does nothing" do
|
||||
visit("/")
|
||||
find("body").send_keys(%i[alt shift arrow_down])
|
||||
|
||||
expect(page).to have_no_selector(".channel-#{channel_1.id}.active")
|
||||
expect(page).to have_no_selector(".channel-#{channel_2.id}.active")
|
||||
expect(page).to have_no_selector(".channel-#{dm_channel_1.id}.active")
|
||||
expect(page).to have_no_selector(".channel-#{dm_channel_2.id}.active")
|
||||
end
|
||||
end
|
||||
|
||||
context "when on chat page" do
|
||||
it "does nothing when no channels have activity" do
|
||||
chat.visit_channel(channel_1)
|
||||
|
||||
expect(page).to have_selector(".channel-#{channel_1.id}.active")
|
||||
|
||||
find("body").send_keys(%i[alt shift arrow_down])
|
||||
|
||||
expect(page).to have_selector(".channel-#{channel_1.id}.active")
|
||||
|
||||
find("body").send_keys(%i[alt shift arrow_down])
|
||||
|
||||
expect(page).to have_selector(".channel-#{channel_1.id}.active")
|
||||
|
||||
find("body").send_keys(%i[alt shift arrow_up])
|
||||
|
||||
expect(page).to have_selector(".channel-#{channel_1.id}.active")
|
||||
end
|
||||
|
||||
it "navigates through the channels with activity" do
|
||||
Fabricate(:chat_message, chat_channel: channel_2, message: "hello!", use_service: true)
|
||||
|
||||
Fabricate(
|
||||
:chat_message,
|
||||
chat_channel: dm_channel_2,
|
||||
message: "hello here!",
|
||||
use_service: true,
|
||||
)
|
||||
|
||||
chat.visit_channel(channel_1)
|
||||
|
||||
expect(page).to have_selector(".channel-#{channel_1.id}.active")
|
||||
|
||||
find("body").send_keys(%i[alt shift arrow_down])
|
||||
|
||||
expect(page).to have_selector(".channel-#{channel_2.id}.active")
|
||||
|
||||
find("body").send_keys(%i[alt shift arrow_down])
|
||||
|
||||
expect(page).to have_selector(".channel-#{dm_channel_2.id}.active")
|
||||
|
||||
Fabricate(
|
||||
:chat_message,
|
||||
chat_channel: channel_1,
|
||||
message: "hello again!",
|
||||
use_service: true,
|
||||
)
|
||||
|
||||
find("body").send_keys(%i[alt shift arrow_up])
|
||||
|
||||
expect(page).to have_selector(".channel-#{channel_1.id}.active")
|
||||
|
||||
Fabricate(:chat_message, chat_channel: dm_channel_1, message: "bye now!", use_service: true)
|
||||
|
||||
find("body").send_keys(%i[alt shift arrow_up])
|
||||
|
||||
expect(page).to have_selector(".channel-#{dm_channel_1.id}.active")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Reference in New Issue