diff --git a/docs/buzz-help.md b/docs/buzz-help.md index e29914ce89..429d22ea0c 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -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 → diff --git a/src/app/message-shortcuts.test.ts b/src/app/message-shortcuts.test.ts index f3ee5629e6..302298c0ba 100644 --- a/src/app/message-shortcuts.test.ts +++ b/src/app/message-shortcuts.test.ts @@ -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("[data-message-id]"); + row?.insertAdjacentHTML( + "afterbegin", + "
Today
", + ); + row + ?.querySelector("[data-layout]") + ?.insertAdjacentHTML( + "beforeend", + '

Text link :wave:

' + + '
code
' + + '
', + ); + 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); diff --git a/src/app/shortcuts.ts b/src/app/shortcuts.ts index 7ef145dd0c..7d571ab7fe 100644 --- a/src/app/shortcuts.ts +++ b/src/app/shortcuts.ts @@ -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, @@ -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(); }; diff --git a/src/features/messages/MessageRow.test.tsx b/src/features/messages/MessageRow.test.tsx index 8075fadb24..912656d3b8 100644 --- a/src/features/messages/MessageRow.test.tsx +++ b/src/features/messages/MessageRow.test.tsx @@ -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( diff --git a/src/features/messages/MessageRow.tsx b/src/features/messages/MessageRow.tsx index 9bd3dcf6dc..73add6cf14 100644 --- a/src/features/messages/MessageRow.tsx +++ b/src/features/messages/MessageRow.tsx @@ -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']", diff --git a/src/features/messages/message-selection.ts b/src/features/messages/message-selection.ts index 9af3faff06..5720a446d7 100644 --- a/src/features/messages/message-selection.ts +++ b/src/features/messages/message-selection.ts @@ -193,3 +193,29 @@ export function moveMessageSelection(event: KeyboardEvent) { 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(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); +}