Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/buzz-help.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,8 @@ revokes all of them, and a pairing unused for 30 days expires.
you change most of them and also shows the fixed message-formatting keys. In
an empty composer, Up selects a message unless **Edit last message with Up Arrow**
is on. With a message selected, E edits it, R reacts, T or → replies in thread,
U marks it unread, and Delete deletes your own message.
U marks it unread, and Delete deletes your own message. Option-click a message
(Alt-click on Windows and Linux) to mark it unread, selected or not.
⌘1–9 switch communities, and ⇧Esc marks everything read. If a plugin uses
the same key as another shortcut, Settings → Shortcuts marks it "Also used
by"; change one of them to choose which fires. In the Mac app, **Buzz →
Expand Down
55 changes: 55 additions & 0 deletions src/app/message-shortcuts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,61 @@ it("deletes, marks unread and replies to the selected message with Slack's keys"
expect(reply).toHaveBeenCalledTimes(2);
});

it("marks a message unread with Option-click, but not from its controls, media, code, day divider or a text selection", () => {
const { controller, reply } = setup(true);
(document.activeElement as HTMLElement | null)?.blur();
const row = document.querySelector<HTMLElement>("[data-message-id]");
row?.insertAdjacentHTML(
"afterbegin",
"<div><span data-day>Today</span></div>",
);
row
?.querySelector("[data-layout]")
?.insertAdjacentHTML(
"beforeend",
'<p>Text <a href="#one">link</a> <img data-copy-emoji=":wave:" alt=":wave:"></p>' +
'<img alt=""><pre>code</pre>' +
'<div role="group" aria-label="Reactions"><button>👍</button></div>',
);
const text = row?.querySelector("p");
const click = (selector: string, init: MouseEventInit = { altKey: true }) =>
row
?.querySelector(selector)
?.dispatchEvent(
new MouseEvent("click", { bubbles: true, cancelable: true, ...init }),
);
click("p", {});
for (const modifier of ["shiftKey", "metaKey", "ctrlKey"])
click("p", { altKey: true, [modifier]: true });
for (const selector of [
"a",
"img:not([data-copy-emoji])",
"pre",
"[aria-label='Reactions']",
"[aria-label='Reply']",
"[data-day]",
])
click(selector);
expect(reply).toHaveBeenCalledTimes(1);
// The end of a drag, including WebKit's Option-drag, leaves text selected.
const range = document.createRange();
if (text) range.selectNodeContents(text);
document.getSelection()?.addRange(range);
click("p");
document.getSelection()?.removeAllRanges();
text?.addEventListener("click", (event) => event.preventDefault(), {
once: true,
});
click("p");
vi.spyOn(controller, "canMarkUnread").mockReturnValueOnce(false);
click("p");
expect(controller.markUnread).not.toHaveBeenCalled();
// A custom emoji is part of the text, not media.
click("[data-copy-emoji]");
expect(controller.markUnread).toHaveBeenCalledExactlyOnceWith({ id: "one" });
expect(document.activeElement).toBe(document.body);
});

it("leaves Delete alone on someone else's message and without a selection", () => {
const { controller } = setup(false);
expect(press("Backspace")).toBe(false);
Expand Down
4 changes: 4 additions & 0 deletions src/app/shortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { Appearance } from "../shared/theme/service";
import { statusShortcut } from "../features/user-status/status-shortcut";
import {
activeMessageList,
markUnreadOnAltClick,
reactionTrigger,
replyTrigger,
selectedMessage,
Expand Down Expand Up @@ -203,6 +204,9 @@ export function registerAppShortcuts(
const settings = () => shortcuts.runHost("settings");
window.addEventListener("buzz:open-settings", settings);
remove.push(() => window.removeEventListener("buzz:open-settings", settings));
// After React's handlers, so clicks they stop or prevent are left alone.
window.addEventListener("click", markUnreadOnAltClick);
remove.push(() => window.removeEventListener("click", markUnreadOnAltClick));
return () => {
for (const dispose of remove) dispose();
};
Expand Down
3 changes: 3 additions & 0 deletions src/features/messages/MessageRow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ it("discloses an automatic failure reply while keeping its original text availab
expect(details).toHaveAccessibleName(
"Spongebob: Couldn’t finish · Hide details",
);
// Option-click marks the message unread, so it leaves the details open.
fireEvent.click(screen.getByText(content), { altKey: true });
expect(details).toHaveAttribute("aria-expanded", "true");
fireEvent.click(details);
expect(details).toHaveAttribute("aria-expanded", "false");
view.rerender(
Expand Down
4 changes: 3 additions & 1 deletion src/features/messages/MessageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1698,11 +1698,13 @@ function AgentsMessageRow(props: MessageRowProps & { failure?: boolean }) {
// button: revealing an anchor takes it out of the Tab order.
data-message-id={row.id}
// Open, a click anywhere in the box folds it, except on its own
// controls and links or at the end of a text selection.
// controls and links, at the end of a text selection, or with
// Option, which is Mark unread's gesture (message-selection.ts).
onClick={(event) => {
const target = event.target as Element;
if (
!open ||
event.altKey ||
!event.currentTarget.contains(target) ||
target.closest(
"a, button, input, textarea, select, summary, [role='button'], [role='menuitem'], [contenteditable='true']",
Expand Down
26 changes: 26 additions & 0 deletions src/features/messages/message-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,3 +193,29 @@ export function moveMessageSelection(event: KeyboardEvent<HTMLElement>) {
focusWithKeyboard(composer);
}
}

const OWN_CLICKS =
"a, button, input, textarea, select, summary, label, [role='button'], [role='menuitem'], [role='group'], [contenteditable='true'], img:not([data-copy-emoji]), video, audio, pre, [data-copy-code]";

/** Slack's Option-click marks a message unread, as U does, from its box only:
* not its day divider, controls, media, code or reactions, nor the end of a
* text selection, which WebKit's Option-drag also makes. */
export function markUnreadOnAltClick(event: MouseEvent) {
const { target } = event;
if (!event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
if (event.defaultPrevented || !(target instanceof Element)) return;
const element = target.closest<HTMLElement>(ROW);
if (!element) return;
const box = focusTarget(element);
if (
!box.contains(target) ||
box.contains(target.closest(OWN_CLICKS)) ||
!target.ownerDocument.getSelection()?.isCollapsed
)
return;
const owner = listFor(element);
const list = owner && lists.get(owner);
const id = element.dataset.messageId;
const row = id ? list?.row(id) : undefined;
if (row && list?.canMarkUnread(row)) list.markUnread(row);
}
Loading