diff --git a/docs/buzz-help.md b/docs/buzz-help.md index 3d9ff88088..c6bdc343a4 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -191,6 +191,10 @@ that agent in Buzz; they do not notify the external app through this connection. before allowing a fresh participation invitation; failed checks keep the original. If the original invitation was applied and the person was later removed, Retry clears the old row without re-adding them. Use **Add** in People to invite them again. +- **Open links in your browser**: on desktop, ⌘-click (Ctrl-click on Windows + and Linux) an HTTP(S) link in a message to open it in your default browser + instead of a side panel. You can also right-click the link and choose + **Open in browser**. - **Sidebar width**: drag the sidebar's right edge, or focus its resize handle and use the Left/Right arrow keys. Double-click the handle to reset the width. Messages and plugin pages share the saved width within the same community diff --git a/src/features/conversation/MessageLink.test.tsx b/src/features/conversation/MessageLink.test.tsx index ddf2bc19b8..b286022413 100644 --- a/src/features/conversation/MessageLink.test.tsx +++ b/src/features/conversation/MessageLink.test.tsx @@ -28,6 +28,7 @@ import { LinkThreadTabContext } from "./LinkLabelContext"; afterEach(() => { cleanup(); vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); const entry: Contribution = { @@ -289,6 +290,76 @@ it("preserves ordinary pane interception and modified or middle-click fallback", expect(onOpenLink).not.toHaveBeenCalled(); }); +function desktopOpener() { + const invoke = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal("isTauri", true); + vi.stubGlobal("__TAURI_INTERNALS__", { invoke }); + return invoke; +} + +it.each([ + "https://github.com/block/buzz-app/pull/1?view=all#discussion_r1", + "http://example.com/path?query=yes#section", +])( + "opens desktop Command-click in the browser with the exact URL: %s", + (target) => { + const invoke = desktopOpener(); + const { anchor, onOpenLink } = mount(target); + const event = new MouseEvent("click", { + bubbles: true, + cancelable: true, + metaKey: true, + }); + fireEvent(anchor, event); + expect(event.defaultPrevented).toBe(true); + expect(invoke).toHaveBeenCalledExactlyOnceWith( + "plugin:opener|open_url", + { + url: target, + }, + undefined, + ); + expect(onOpenLink).not.toHaveBeenCalled(); + }, +); + +it("keeps ordinary desktop clicks in the pane", () => { + const invoke = desktopOpener(); + const { anchor, onOpenLink } = mount(); + fireEvent.click(anchor); + expect(onOpenLink).toHaveBeenCalledExactlyOnceWith(url); + expect(invoke).not.toHaveBeenCalled(); +}); + +it("reports a failed desktop browser open and clears the error on retry", async () => { + const invoke = desktopOpener(); + invoke.mockRejectedValueOnce("Browser unavailable"); + const { anchor, onOpenLink } = mount(); + fireEvent.click(anchor, { metaKey: true }); + expect( + await screen.findByText("Couldn’t open the link in your browser."), + ).toBeInTheDocument(); + fireEvent.click(anchor, { metaKey: true }); + expect( + screen.queryByText("Couldn’t open the link in your browser."), + ).not.toBeInTheDocument(); + expect(invoke).toHaveBeenCalledTimes(2); + expect(onOpenLink).not.toHaveBeenCalled(); +}); + +it("does not open a desktop browser from an inactive conversation", () => { + const invoke = desktopOpener(); + const onOpenLink = vi.fn(() => true); + render( + + + , + ); + fireEvent.click(screen.getByRole("link"), { metaKey: true }); + expect(invoke).not.toHaveBeenCalled(); + expect(onOpenLink).not.toHaveBeenCalled(); +}); + it("retires an external menu when its retained conversation becomes inactive", async () => { const onOpenLink = vi.fn(() => true); const tree = (active: boolean) => ( diff --git a/src/features/conversation/MessageLink.tsx b/src/features/conversation/MessageLink.tsx index 9c9eb4a101..50f3bc6dd7 100644 --- a/src/features/conversation/MessageLink.tsx +++ b/src/features/conversation/MessageLink.tsx @@ -1,3 +1,4 @@ +import { invoke, isTauri } from "@tauri-apps/api/core"; import { useConversationPresentation } from "./ConversationPresentation"; import { useContext, @@ -226,6 +227,18 @@ export function MessageLink({ openInternal(); return; } + // Tauri's automatic external-link opener skips Command-clicks. + if (external && event.metaKey && event.button === 0 && isTauri()) { + event.preventDefault(); + setNotice(undefined); + void invoke("plugin:opener|open_url", { url }).catch(() => { + setNotice({ + text: "Couldn’t open the link in your browser.", + error: true, + }); + }); + return; + } if ( !event.metaKey && !event.ctrlKey &&