From 70b53f8b2a325aa339b0dde16bf359877e28a2c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20R=C3=A9mond?= Date: Fri, 10 Jul 2026 14:58:34 +0200 Subject: [PATCH 1/9] docs(spec): copy-link context menu for chat messages (#908) --- ...026-07-10-copy-link-context-menu-design.md | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-10-copy-link-context-menu-design.md diff --git a/docs/superpowers/specs/2026-07-10-copy-link-context-menu-design.md b/docs/superpowers/specs/2026-07-10-copy-link-context-menu-design.md new file mode 100644 index 00000000..ef0eda35 --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-copy-link-context-menu-design.md @@ -0,0 +1,124 @@ +# Copy a link from a chat message — design + +Issue: [#908](https://github.com/processone/fluux-messenger/issues/908) — "Right-click to copy website link not working in chat message" + +## Problem + +On **packaged desktop (Tauri) builds**, right-clicking a link inside a chat message +does nothing. `useNativeContextMenuSuppression` +(`apps/fluux/src/hooks/useNativeContextMenuSuppression.ts`) globally swallows the +WebView's native context menu (to hide "Reload / Inspect Element / Save Image As…") +everywhere except editable fields and active text selections. Message-body links are +rendered as plain `` with no context-menu handling of their +own, so on desktop there is neither a native nor an app menu — right-click is a no-op. + +On web/PWA and `tauri:dev` the native menu still works, which is why the bug only +reproduces in the packaged app. + +There is an existing precedent for the fix: `apps/fluux/src/components/ImageContextMenu.tsx` +gives images a custom app-level menu (Copy URL / Open in browser / Save). + +## Approach + +Provide a **custom app-level menu** for links (Option B), applied on **all builds** +(consistent with how `ImageContextMenu` already overrides the native menu on images). +Actions: **Copy link** and **Open in browser**. + +Two surfaces, matched to each platform's input: + +| Platform | Trigger | UI | +|---|---|---| +| Desktop | Right-click **on the link itself** | `LinkContextMenu` popover — *Copy link*, *Open in browser* | +| Touch | Long-press the **bubble** → existing action sheet | New *Copy link* row; if >1 link, a second in-sheet chooser | + +This split deliberately resolves a mobile interaction conflict: the message bubble +already owns a long-press handler that opens `MessageActionSheet` +(`apps/fluux/src/components/conversation/MessageBubble.tsx`). A link that also handled +long-press would fire two menus at once. So the link wires **only right-click**; the +bubble's action sheet remains the single mobile surface and simply grows a *Copy link* +entry. + +## Coverage + +Both link render sites route through the new shared component: +- Message bodies — `apps/fluux/src/utils/messageStyles.tsx:298` (`renderSegment`). +- Room subjects / headers — `apps/fluux/src/utils/messageStyles.tsx:630` + (`renderTextWithLinks`). + +## Components + +### 1. `extractLinks(text): string[]` +New exported helper in `messageStyles.tsx`. Reuses the existing `URL_REGEX` +(`messageStyles.tsx:28`), returns links in document order, de-duplicated. Single source +of truth for "what links are in this body," consumed by `MessageActionSheet`. +Must reset `URL_REGEX.lastIndex` before iterating (the regex is `/g`). + +### 2. `openInBrowser(url): Promise` +Extract the helper currently **duplicated** inside `ImageContextMenu.tsx` (lines 17–24) +into `apps/fluux/src/utils/openInBrowser.ts`: +- Tauri: dynamic `import('@tauri-apps/plugin-shell')` → `open(url)`. +- Web: `window.open(url, '_blank', 'noopener,noreferrer')`. + +`ImageContextMenu` and the new `LinkContextMenu` both import it. (CLAUDE.md: avoid +duplicate code.) + +### 3. `LinkContextMenu.tsx` +New component modeled on `ImageContextMenu`. Props: `url: string` and +`menu: ContextMenuState`. Renders: +- **Copy link** → `copyToClipboard(url)` (`@/utils/clipboard`). +- **Open in browser** → `openInBrowser(url)`. + +Reuses `MenuButton` (`./sidebar-components/SidebarListMenu`), the `fluux-popover` +styling, and `useFocusTrap`. Returns `null` when `!menu.isOpen`. + +### 4. `MessageLink.tsx` +New component that replaces the raw `` at both render sites. Props: `href: string`, +`children: React.ReactNode` (defaults to the URL text), plus the existing className / +`target="_blank"` / `rel="noopener noreferrer"`. + +- Owns one `useContextMenu()` instance. +- Wires **only `onContextMenu={menu.handleContextMenu}`** — no `onTouchStart`/ + `onTouchEnd` (touch is intentionally left to the bubble action sheet). +- Renders `` via `createPortal` to + `document.body`. Portal is required so the menu's `position: fixed` is not offset by + the virtualizer's row `transform` (a fixed element inside a transformed ancestor is + positioned relative to that ancestor). `messageStyles.tsx` already imports + `createPortal`. + +### 5. `MessageActionSheet.tsx` +Derive `links = extractLinks(body)` internally (no new prop; `body` is already passed). +Add a **Copy link** `MenuButton` (icon: `Link` from lucide) when `links.length >= 1`, +placed near the existing *Copy message* row: +- `links.length === 1` → copy that link, close. +- `links.length > 1` → toggle an in-sheet chooser view, reusing the existing + `showEmojiPicker`-style toggle pattern (new `showLinkPicker` state). The chooser lists + each URL as a `MenuButton`; tapping one copies it and closes. Reset `showLinkPicker` + on close (mirrors the emoji-picker reset in `close()`). + +### 6. i18n +New keys under `chat`: +- `chat.copyLink` — "Copy link" +- `chat.copyLinkChoose` — chooser header, e.g. "Copy which link?" + +`chat.openInBrowser` already exists (used by `ImageContextMenu`). All 33 locales +translated (no English placeholders); no em-dash connectors; surgical locale edits +(parse → mutate → `stringify(, , 4) + "\n"`). + +## Testing + +- `extractLinks` unit test: none / one / many / duplicate / trailing-punctuation cases. +- `LinkContextMenu`: *Copy link* calls the clipboard; *Open in browser* calls + `openInBrowser`. +- `MessageLink`: right-click (`contextmenu`) opens the menu; a component that already + called `preventDefault` is respected (existing `useContextMenu` behavior). +- `MessageActionSheet`: *Copy link* row hidden with 0 links; copies directly with 1; + opens the chooser with >1; chooser entries copy the right URL. +- Any new SDK/app exports asserted by tests added to the relevant test mocks / + `test-setup.ts` i18n subset as needed. + +## Out of scope + +- Desktop hover toolbar (`MessageToolbar`) gets no *Copy link* button — right-clicking + the link directly is the more precise desktop path. +- No change to `useNativeContextMenuSuppression`; the custom menu's `preventDefault` + already stops the native menu, and suppression stays correct elsewhere. From ddc00a57ce29564bfe15e258f58d5e559da4fe66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20R=C3=A9mond?= Date: Fri, 10 Jul 2026 15:03:35 +0200 Subject: [PATCH 2/9] docs(plan): copy-link context menu implementation plan (#908) --- .../2026-07-10-copy-link-context-menu.md | 727 ++++++++++++++++++ 1 file changed, 727 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-10-copy-link-context-menu.md diff --git a/docs/superpowers/plans/2026-07-10-copy-link-context-menu.md b/docs/superpowers/plans/2026-07-10-copy-link-context-menu.md new file mode 100644 index 00000000..40b618e9 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-copy-link-context-menu.md @@ -0,0 +1,727 @@ +# Copy-link Context Menu Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let users copy (and open) a website link from a chat message — via right-click on desktop and via the touch action sheet on mobile. + +**Architecture:** A shared `MessageLink` component replaces the raw `` at both link render sites and owns a right-click-only `useContextMenu` that shows a `LinkContextMenu` popover (Copy link / Open in browser), mirroring the existing `ImageContextMenu`. On touch, the message bubble's existing `MessageActionSheet` grows a *Copy link* row (with a chooser when a message holds several links). A duplicated `openInBrowser` helper is extracted to a shared util. + +**Tech Stack:** React 19, TypeScript, Tailwind, Vitest + Testing Library, react-i18next, Tauri (`@tauri-apps/plugin-shell`). + +## Global Constraints + +- SDK/app split: this is all in `apps/fluux` — no SDK changes. +- i18n: every new key added to **all 33 locales** in `apps/fluux/src/i18n/locales/` with real translations (no English placeholders); **no em-dash connectors**; surgical edits (parse → mutate → `JSON.stringify(obj, null, 4) + "\n"`, never reformat the whole file). +- Never include a Claude footer in commit messages. +- App tests run under happy-dom; DOM/interaction tests that need a real layout pin `// @vitest-environment jsdom` at the top of the file. +- Run app tests per-workspace: `cd apps/fluux && npx vitest run ` (never bare root `vitest`). +- Reuse existing building blocks: `MenuButton` (`apps/fluux/src/components/sidebar-components/SidebarListMenu.tsx`), `copyToClipboard` (`apps/fluux/src/utils/clipboard.ts`), `useContextMenu` (`apps/fluux/src/hooks/useContextMenu.ts`), `useFocusTrap` (`apps/fluux/src/hooks/useFocusTrap.ts`), `fluux-popover` styling. + +--- + +## File Structure + +- Create `apps/fluux/src/utils/openInBrowser.ts` — Tauri/web "open URL externally" helper (extracted from `ImageContextMenu`). +- Create `apps/fluux/src/components/LinkContextMenu.tsx` — right-click popover for a single link. +- Create `apps/fluux/src/components/conversation/MessageLink.tsx` — `` wrapper that owns the right-click menu. +- Modify `apps/fluux/src/components/ImageContextMenu.tsx` — use the shared `openInBrowser`. +- Modify `apps/fluux/src/utils/messageStyles.tsx` — add `extractLinks`; route both `` sites through `MessageLink`. +- Modify `apps/fluux/src/components/conversation/MessageActionSheet.tsx` — add *Copy link* row + chooser. +- Modify all 33 `apps/fluux/src/i18n/locales/*.json` — add `chat.copyLink`, `chat.copyLinkChoose`. +- Modify `apps/fluux/src/test-setup.ts` — add the two new keys to the `chat` i18n subset. +- Tests: `openInBrowser.test.ts`, `LinkContextMenu.test.tsx`, `MessageLink.test.tsx`, `messageStyles` extractLinks test, `MessageActionSheet.test.tsx`. + +--- + +## Task 1: i18n keys + +**Files:** +- Modify: `apps/fluux/src/i18n/locales/en.json` (after line 491, `"copyImageUrl"`) +- Modify: all other 32 files in `apps/fluux/src/i18n/locales/*.json` +- Modify: `apps/fluux/src/test-setup.ts:102` (`chat` block) + +**Interfaces:** +- Produces: i18n keys `chat.copyLink`, `chat.copyLinkChoose` in every locale and in the test i18n subset. + +- [ ] **Step 1: Add keys to `en.json`** + +Insert into the `chat` object, right after `"copyImageUrl": "Copy image URL",` (line 491): + +```json + "copyLink": "Copy link", + "copyLinkChoose": "Copy which link?", +``` + +- [ ] **Step 2: Add keys to the remaining 32 locales** + +For each other `apps/fluux/src/i18n/locales/.json`, add `copyLink` and `copyLinkChoose` inside its `chat` object with real translations for that language (translate yourself; no English placeholders; no em-dash). Use surgical edits — parse the file, add the two keys, re-serialize with `JSON.stringify(obj, null, 4) + "\n"`. + +- [ ] **Step 3: Add keys to the test i18n subset** + +In `apps/fluux/src/test-setup.ts`, extend the `chat` block (line 102) so asserted labels resolve: + +```ts + chat: { + typing: { + one: '{{name}} is typing...', + two: '{{name1}} and {{name2}} are typing...', + three: '{{name1}}, {{name2}}, and {{name3}} are typing...', + many: '{{name1}}, {{name2}}, and {{count}} others are typing...', + }, + newMessagesCount: '{{count}} new message', + newMessagesCount_other: '{{count}} new messages', + youWereAway: 'You were away', + copyLink: 'Copy link', + copyLinkChoose: 'Copy which link?', + openInBrowser: 'Open in browser', + copyMessage: 'Copy text', + }, +``` + +- [ ] **Step 4: Verify all locales parse and contain the keys** + +Run: `cd apps/fluux && node -e "const fs=require('fs');const d='src/i18n/locales';let bad=[];for(const f of fs.readdirSync(d)){if(!f.endsWith('.json'))continue;const o=JSON.parse(fs.readFileSync(d+'/'+f));if(!o.chat||!o.chat.copyLink||!o.chat.copyLinkChoose)bad.push(f);}console.log(bad.length?('MISSING: '+bad.join(', ')):'OK all 33');"` +Expected: `OK all 33` + +- [ ] **Step 5: Commit** + +```bash +git add apps/fluux/src/i18n/locales apps/fluux/src/test-setup.ts +git commit -m "i18n(chat): add copyLink / copyLinkChoose keys (#908)" +``` + +--- + +## Task 2: Shared `openInBrowser` util + +**Files:** +- Create: `apps/fluux/src/utils/openInBrowser.ts` +- Test: `apps/fluux/src/utils/openInBrowser.test.ts` +- Modify: `apps/fluux/src/components/ImageContextMenu.tsx:17-24` (remove local copy, import shared) + +**Interfaces:** +- Produces: `export async function openInBrowser(url: string): Promise`. + +- [ ] **Step 1: Write the failing test** + +Create `apps/fluux/src/utils/openInBrowser.test.ts`: + +```ts +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const openMock = vi.fn() +vi.mock('@tauri-apps/plugin-shell', () => ({ open: openMock })) + +describe('openInBrowser', () => { + beforeEach(() => { + vi.resetModules() + openMock.mockReset() + }) + + it('uses window.open on web', async () => { + vi.doMock('./tauri', () => ({ isTauri: () => false })) + const winOpen = vi.spyOn(window, 'open').mockImplementation(() => null) + const { openInBrowser } = await import('./openInBrowser') + await openInBrowser('https://example.com') + expect(winOpen).toHaveBeenCalledWith('https://example.com', '_blank', 'noopener,noreferrer') + expect(openMock).not.toHaveBeenCalled() + }) + + it('uses the Tauri shell open on desktop', async () => { + vi.doMock('./tauri', () => ({ isTauri: () => true })) + const { openInBrowser } = await import('./openInBrowser') + await openInBrowser('https://example.com') + expect(openMock).toHaveBeenCalledWith('https://example.com') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/fluux && npx vitest run src/utils/openInBrowser.test.ts` +Expected: FAIL — cannot resolve `./openInBrowser`. + +- [ ] **Step 3: Write the util** + +Create `apps/fluux/src/utils/openInBrowser.ts`: + +```ts +import { isTauri } from './tauri' + +/** + * Open a URL in the user's default browser. + * + * On the Tauri desktop app this hands off to the OS via the shell plugin so the + * link opens in the real browser (not a new WebView window). On web/PWA it falls + * back to `window.open` with `noopener,noreferrer`. + */ +export async function openInBrowser(url: string): Promise { + if (isTauri()) { + const { open } = await import('@tauri-apps/plugin-shell') + await open(url) + } else { + window.open(url, '_blank', 'noopener,noreferrer') + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd apps/fluux && npx vitest run src/utils/openInBrowser.test.ts` +Expected: PASS (2 tests). + +- [ ] **Step 5: Refactor `ImageContextMenu` to use it** + +In `apps/fluux/src/components/ImageContextMenu.tsx`, delete the local `openInBrowser` function (lines 17-24) and its now-unused `isTauri` import, and add: + +```ts +import { openInBrowser } from '@/utils/openInBrowser' +``` + +Leave the rest (the `handleOpenInBrowser` caller) unchanged. + +- [ ] **Step 6: Verify ImageContextMenu still typechecks and no dead imports** + +Run: `cd apps/fluux && npx vitest run src/utils/openInBrowser.test.ts && npx tsc --noEmit -p .` +Expected: tests PASS; tsc reports no errors (in particular no "isTauri declared but never used"). + +- [ ] **Step 7: Commit** + +```bash +git add apps/fluux/src/utils/openInBrowser.ts apps/fluux/src/utils/openInBrowser.test.ts apps/fluux/src/components/ImageContextMenu.tsx +git commit -m "refactor(links): extract shared openInBrowser util (#908)" +``` + +--- + +## Task 3: `extractLinks` helper + +**Files:** +- Modify: `apps/fluux/src/utils/messageStyles.tsx` (add exported function near `URL_REGEX`, line 28) +- Test: `apps/fluux/src/utils/messageStyles.extractLinks.test.ts` + +**Interfaces:** +- Consumes: existing `URL_REGEX` in `messageStyles.tsx:28`. +- Produces: `export function extractLinks(text: string): string[]` — links in document order, de-duplicated. + +- [ ] **Step 1: Write the failing test** + +Create `apps/fluux/src/utils/messageStyles.extractLinks.test.ts`: + +```ts +import { describe, it, expect } from 'vitest' +import { extractLinks } from './messageStyles' + +describe('extractLinks', () => { + it('returns [] when there are no links', () => { + expect(extractLinks('just some text')).toEqual([]) + }) + + it('extracts a single link', () => { + expect(extractLinks('see https://example.com now')).toEqual(['https://example.com']) + }) + + it('extracts multiple links in document order', () => { + expect(extractLinks('a https://a.com b https://b.com')).toEqual([ + 'https://a.com', + 'https://b.com', + ]) + }) + + it('de-duplicates identical links', () => { + expect(extractLinks('https://a.com and again https://a.com')).toEqual(['https://a.com']) + }) + + it('strips trailing sentence punctuation', () => { + expect(extractLinks('go to https://example.com.')).toEqual(['https://example.com']) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/fluux && npx vitest run src/utils/messageStyles.extractLinks.test.ts` +Expected: FAIL — `extractLinks` is not exported. + +- [ ] **Step 3: Implement the helper** + +In `apps/fluux/src/utils/messageStyles.tsx`, immediately after the `URL_REGEX` definition (line 28), add: + +```ts +/** + * Return every http(s) URL found in `text`, in document order, de-duplicated. + * Shares URL_REGEX with the message renderer so "what is a link" stays consistent + * between the rendered text and the copy-link affordances. + */ +export function extractLinks(text: string): string[] { + if (!text) return [] + URL_REGEX.lastIndex = 0 + const seen = new Set() + const out: string[] = [] + let match: RegExpExecArray | null + while ((match = URL_REGEX.exec(text)) !== null) { + const url = match[0] + if (!seen.has(url)) { + seen.add(url) + out.push(url) + } + } + return out +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd apps/fluux && npx vitest run src/utils/messageStyles.extractLinks.test.ts` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add apps/fluux/src/utils/messageStyles.tsx apps/fluux/src/utils/messageStyles.extractLinks.test.ts +git commit -m "feat(links): add extractLinks helper (#908)" +``` + +--- + +## Task 4: `LinkContextMenu` component + +**Files:** +- Create: `apps/fluux/src/components/LinkContextMenu.tsx` +- Test: `apps/fluux/src/components/LinkContextMenu.test.tsx` + +**Interfaces:** +- Consumes: `openInBrowser` (Task 2); `copyToClipboard`; `MenuButton`; `useFocusTrap`; `ContextMenuState` from `useContextMenu`. +- Produces: `export function LinkContextMenu(props: { url: string; menu: ContextMenuState }): JSX.Element | null`. + +- [ ] **Step 1: Write the failing test** + +Create `apps/fluux/src/components/LinkContextMenu.test.tsx`: + +```tsx +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { createRef } from 'react' +import { LinkContextMenu } from './LinkContextMenu' +import type { ContextMenuState } from '@/hooks/useContextMenu' + +const copyMock = vi.fn() +vi.mock('@/utils/clipboard', () => ({ copyToClipboard: (t: string) => copyMock(t) })) +const openMock = vi.fn() +vi.mock('@/utils/openInBrowser', () => ({ openInBrowser: (u: string) => openMock(u) })) + +function makeMenu(isOpen: boolean): ContextMenuState { + return { + isOpen, + position: { x: 10, y: 20 }, + menuRef: createRef(), + longPressTriggered: createRef() as ContextMenuState['longPressTriggered'], + close: vi.fn(), + handleContextMenu: vi.fn(), + handleTouchStart: vi.fn(), + handleTouchEnd: vi.fn(), + } +} + +describe('LinkContextMenu', () => { + beforeEach(() => { + copyMock.mockReset() + openMock.mockReset() + }) + + it('renders nothing when closed', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('copies the link', () => { + render() + fireEvent.click(screen.getByText('Copy link')) + expect(copyMock).toHaveBeenCalledWith('https://x.com') + }) + + it('opens the link in the browser', () => { + render() + fireEvent.click(screen.getByText('Open in browser')) + expect(openMock).toHaveBeenCalledWith('https://x.com') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/fluux && npx vitest run src/components/LinkContextMenu.test.tsx` +Expected: FAIL — cannot resolve `./LinkContextMenu`. + +- [ ] **Step 3: Write the component** + +Create `apps/fluux/src/components/LinkContextMenu.tsx`: + +```tsx +import { useTranslation } from 'react-i18next' +import { Copy, ExternalLink } from 'lucide-react' +import { MenuButton } from './sidebar-components/SidebarListMenu' +import { copyToClipboard } from '@/utils/clipboard' +import { openInBrowser } from '@/utils/openInBrowser' +import type { ContextMenuState } from '@/hooks/useContextMenu' +import { useFocusTrap } from '@/hooks/useFocusTrap' + +interface LinkContextMenuProps { + url: string + menu: ContextMenuState +} + +/** + * Right-click / long-press menu for a hyperlink in a message. Mirrors + * ImageContextMenu: a small popover positioned at the click point with + * "Copy link" and "Open in browser". Rendered by MessageLink. + */ +export function LinkContextMenu({ url, menu }: LinkContextMenuProps) { + const { t } = useTranslation() + useFocusTrap(menu.menuRef, { active: menu.isOpen }) + + if (!menu.isOpen) return null + + const handleCopy = () => { + menu.close() + void copyToClipboard(url) + } + + const handleOpen = () => { + menu.close() + void openInBrowser(url) + } + + return ( +
+ } label={t('chat.copyLink')} /> + } + label={t('chat.openInBrowser')} + /> +
+ ) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd apps/fluux && npx vitest run src/components/LinkContextMenu.test.tsx` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add apps/fluux/src/components/LinkContextMenu.tsx apps/fluux/src/components/LinkContextMenu.test.tsx +git commit -m "feat(links): add LinkContextMenu popover (#908)" +``` + +--- + +## Task 5: `MessageLink` component + wire both render sites + +**Files:** +- Create: `apps/fluux/src/components/conversation/MessageLink.tsx` +- Test: `apps/fluux/src/components/conversation/MessageLink.test.tsx` +- Modify: `apps/fluux/src/utils/messageStyles.tsx:298-309` (link case in `renderSegment`) +- Modify: `apps/fluux/src/utils/messageStyles.tsx:629-639` (`renderTextWithLinks`) + +**Interfaces:** +- Consumes: `useContextMenu`; `LinkContextMenu` (Task 4); `createPortal`. +- Produces: `export function MessageLink(props: { href: string; children?: React.ReactNode; className?: string }): JSX.Element`. + +- [ ] **Step 1: Write the failing test** + +Create `apps/fluux/src/components/conversation/MessageLink.test.tsx`: + +```tsx +// @vitest-environment jsdom +import { describe, it, expect } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { MessageLink } from './MessageLink' + +describe('MessageLink', () => { + it('renders an anchor to the href', () => { + render() + const a = screen.getByRole('link', { name: 'https://example.com' }) + expect(a).toHaveAttribute('href', 'https://example.com') + expect(a).toHaveAttribute('target', '_blank') + }) + + it('opens the context menu on right-click', () => { + render() + // menu is closed initially + expect(screen.queryByText('Copy link')).toBeNull() + fireEvent.contextMenu(screen.getByRole('link')) + expect(screen.getByText('Copy link')).toBeInTheDocument() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/fluux && npx vitest run src/components/conversation/MessageLink.test.tsx` +Expected: FAIL — cannot resolve `./MessageLink`. + +- [ ] **Step 3: Write the component** + +Create `apps/fluux/src/components/conversation/MessageLink.tsx`: + +```tsx +import type { ReactNode } from 'react' +import { createPortal } from 'react-dom' +import { useContextMenu } from '@/hooks/useContextMenu' +import { LinkContextMenu } from '../LinkContextMenu' + +interface MessageLinkProps { + href: string + children?: ReactNode + className?: string +} + +/** + * A hyperlink inside a message. Left-click follows the link (handled globally by + * externalLinkHandler); right-click opens LinkContextMenu (Copy link / Open in + * browser) so the URL can be copied even on packaged desktop builds where the + * native WebView menu is suppressed. + * + * Touch long-press is intentionally NOT wired here: the message bubble already + * owns a long-press that opens MessageActionSheet, which carries its own Copy-link + * affordance. The menu is portalled to document.body so its `position: fixed` + * isn't offset by the virtualizer's row transforms. + */ +export function MessageLink({ href, children, className = 'text-fluux-link hover:underline' }: MessageLinkProps) { + const menu = useContextMenu() + return ( + <> +
+ {children ?? href} + + {menu.isOpen && createPortal(, document.body)} + + ) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd apps/fluux && npx vitest run src/components/conversation/MessageLink.test.tsx` +Expected: PASS (2 tests). + +- [ ] **Step 5: Wire `renderSegment` (message body)** + +In `apps/fluux/src/utils/messageStyles.tsx`, add the import near the top (after the existing local imports, e.g. below line 25): + +```ts +import { MessageLink } from '../components/conversation/MessageLink' +``` + +Replace the `case 'link'` block (lines 298-309) with: + +```tsx + case 'link': + return +``` + +- [ ] **Step 6: Wire `renderTextWithLinks` (room subjects)** + +In the same file, replace the `` pushed inside `renderTextWithLinks` (lines 629-639) with: + +```tsx + parts.push() +``` + +- [ ] **Step 7: Run the messageStyles tests to confirm no regressions** + +Run: `cd apps/fluux && npx vitest run src/utils/messageStyles.test.tsx src/components/conversation/MessageLink.test.tsx` +Expected: PASS. (If a snapshot in `messageStyles.test.tsx` captured the old `` markup, update it with `-u` after confirming the new output renders the link text and href.) + +- [ ] **Step 8: Commit** + +```bash +git add apps/fluux/src/components/conversation/MessageLink.tsx apps/fluux/src/components/conversation/MessageLink.test.tsx apps/fluux/src/utils/messageStyles.tsx apps/fluux/src/utils/__snapshots__ 2>/dev/null; git commit -m "feat(links): right-click copy/open via MessageLink (#908)" +``` + +--- + +## Task 6: `MessageActionSheet` Copy-link row + chooser + +**Files:** +- Modify: `apps/fluux/src/components/conversation/MessageActionSheet.tsx` +- Test: `apps/fluux/src/components/conversation/MessageActionSheet.test.tsx` + +**Interfaces:** +- Consumes: `extractLinks` (Task 3); `copyToClipboard`; existing `body` prop. +- Produces: no new public API — internal *Copy link* row and chooser view. + +- [ ] **Step 1: Write the failing tests** + +Add to `apps/fluux/src/components/conversation/MessageActionSheet.test.tsx` (create if missing; keep any existing cases). Full file if creating: + +```tsx +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { MessageActionSheet } from './MessageActionSheet' + +const copyMock = vi.fn() +vi.mock('@/utils/clipboard', () => ({ copyToClipboard: (t: string) => copyMock(t) })) + +const baseProps = { + open: true, + onClose: vi.fn(), + myReactions: [], + onReply: vi.fn(), + onEdit: vi.fn(), + onDelete: vi.fn(), + canReply: false, + canEdit: false, + canDelete: false, +} + +describe('MessageActionSheet copy-link', () => { + beforeEach(() => copyMock.mockReset()) + + it('hides Copy link when the body has no links', () => { + render() + expect(screen.queryByText('Copy link')).toBeNull() + }) + + it('copies the only link directly', () => { + render() + fireEvent.click(screen.getByText('Copy link')) + expect(copyMock).toHaveBeenCalledWith('https://a.com') + }) + + it('opens a chooser when there are several links', () => { + render() + fireEvent.click(screen.getByText('Copy link')) + // chooser header appears; both URLs are listed + expect(screen.getByText('Copy which link?')).toBeInTheDocument() + fireEvent.click(screen.getByText('https://b.com')) + expect(copyMock).toHaveBeenCalledWith('https://b.com') + }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd apps/fluux && npx vitest run src/components/conversation/MessageActionSheet.test.tsx` +Expected: FAIL — no "Copy link" element. + +- [ ] **Step 3: Implement the row + chooser** + +In `apps/fluux/src/components/conversation/MessageActionSheet.tsx`: + +Add imports (extend the existing lucide import and add the helpers): + +```ts +import { Reply, Pencil, Trash2, Copy, SmilePlus, Link2 } from 'lucide-react' +import { extractLinks } from '../../utils/messageStyles' +import { copyToClipboard } from '@/utils/clipboard' +``` + +Add a chooser state next to `showEmojiPicker` (line 52): + +```ts + const [showLinkPicker, setShowLinkPicker] = useState(false) +``` + +Extend `close()` (line 55) to also reset the link picker: + +```ts + const close = () => { + setShowEmojiPicker(false) + setShowLinkPicker(false) + onClose() + } +``` + +Derive links and a copy handler (near `canCopy`, line 75): + +```ts + const links = extractLinks(body ?? '') + const copyLink = (url: string) => { + void copyToClipboard(url) + close() + } + const onCopyLinkClick = () => { + if (links.length === 1) copyLink(links[0]) + else setShowLinkPicker(true) + } +``` + +Render the chooser as a third branch of the top-level conditional. Change the opening of the render conditional (line 79) from `{showEmojiPicker ? (...) : (...)}` to a nested form so the link picker takes priority when active: + +```tsx + {showLinkPicker ? ( +
+
{t('chat.copyLinkChoose')}
+ {links.map((url) => ( + copyLink(url)} + icon={} + label={url} + className="py-3 [&_span]:min-w-0 [&_span]:truncate" + /> + ))} +
+ ) : showEmojiPicker ? ( +``` + +(The existing `showEmojiPicker` branch and its `: (` else-branch stay as-is after this.) + +Add the *Copy link* row inside the action rows block, immediately after the `canCopy` (Copy text) `MenuButton` (after line 132): + +```tsx + {links.length > 0 && ( + } + label={t('chat.copyLink')} + className="py-3" + /> + )} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd apps/fluux && npx vitest run src/components/conversation/MessageActionSheet.test.tsx` +Expected: PASS (3 new tests + any pre-existing). + +- [ ] **Step 5: Full typecheck + affected tests** + +Run: `cd apps/fluux && npx tsc --noEmit -p . && npx vitest run src/components/conversation/MessageActionSheet.test.tsx src/components/conversation/MessageLink.test.tsx src/components/LinkContextMenu.test.tsx src/utils/messageStyles.extractLinks.test.ts src/utils/openInBrowser.test.ts` +Expected: tsc clean; all tests PASS. + +- [ ] **Step 6: Commit** + +```bash +git add apps/fluux/src/components/conversation/MessageActionSheet.tsx apps/fluux/src/components/conversation/MessageActionSheet.test.tsx +git commit -m "feat(links): copy-link row + chooser in message action sheet (#908)" +``` + +--- + +## Final verification + +- [ ] **Run the app test suite + typecheck + lint** + +Run: `cd apps/fluux && npx tsc --noEmit -p . && npx eslint src/components/LinkContextMenu.tsx src/components/conversation/MessageLink.tsx src/components/conversation/MessageActionSheet.tsx src/utils/openInBrowser.ts src/utils/messageStyles.tsx && npx vitest run` +Expected: clean typecheck, no lint errors, green suite (no unrelated failures introduced). + +- [ ] **Manual smoke (desktop):** In `npm run dev` demo mode, send/open a message with a link, right-click the link → *Copy link* / *Open in browser* appear and work. (Note: `tauri:dev` keeps the native menu, so the app menu is verified on web; packaged-build suppression is the same code path.) +- [ ] **Manual smoke (touch):** Resize to mobile, long-press a message bubble with one link → *Copy link* copies it; with two links → chooser lists both. From feb86b371eed56facef1c655273d6856508f2e83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20R=C3=A9mond?= Date: Fri, 10 Jul 2026 15:09:27 +0200 Subject: [PATCH 3/9] i18n(chat): add copyLink / copyLinkChoose keys (#908) --- apps/fluux/src/i18n/locales/ar.json | 2 ++ apps/fluux/src/i18n/locales/be.json | 2 ++ apps/fluux/src/i18n/locales/bg.json | 2 ++ apps/fluux/src/i18n/locales/ca.json | 2 ++ apps/fluux/src/i18n/locales/cs.json | 2 ++ apps/fluux/src/i18n/locales/da.json | 2 ++ apps/fluux/src/i18n/locales/de.json | 2 ++ apps/fluux/src/i18n/locales/el.json | 2 ++ apps/fluux/src/i18n/locales/en.json | 2 ++ apps/fluux/src/i18n/locales/es.json | 2 ++ apps/fluux/src/i18n/locales/et.json | 2 ++ apps/fluux/src/i18n/locales/fi.json | 2 ++ apps/fluux/src/i18n/locales/fr.json | 2 ++ apps/fluux/src/i18n/locales/ga.json | 2 ++ apps/fluux/src/i18n/locales/he.json | 2 ++ apps/fluux/src/i18n/locales/hr.json | 2 ++ apps/fluux/src/i18n/locales/hu.json | 2 ++ apps/fluux/src/i18n/locales/is.json | 2 ++ apps/fluux/src/i18n/locales/it.json | 2 ++ apps/fluux/src/i18n/locales/lt.json | 2 ++ apps/fluux/src/i18n/locales/lv.json | 2 ++ apps/fluux/src/i18n/locales/mt.json | 2 ++ apps/fluux/src/i18n/locales/nb.json | 2 ++ apps/fluux/src/i18n/locales/nl.json | 2 ++ apps/fluux/src/i18n/locales/pl.json | 2 ++ apps/fluux/src/i18n/locales/pt.json | 2 ++ apps/fluux/src/i18n/locales/ro.json | 2 ++ apps/fluux/src/i18n/locales/ru.json | 2 ++ apps/fluux/src/i18n/locales/sk.json | 2 ++ apps/fluux/src/i18n/locales/sl.json | 2 ++ apps/fluux/src/i18n/locales/sv.json | 2 ++ apps/fluux/src/i18n/locales/uk.json | 2 ++ apps/fluux/src/i18n/locales/zh-CN.json | 2 ++ apps/fluux/src/test-setup.ts | 4 ++++ 34 files changed, 70 insertions(+) diff --git a/apps/fluux/src/i18n/locales/ar.json b/apps/fluux/src/i18n/locales/ar.json index 76235f39..2e3bb9f2 100644 --- a/apps/fluux/src/i18n/locales/ar.json +++ b/apps/fluux/src/i18n/locales/ar.json @@ -493,6 +493,8 @@ "moderateAndBan": "حظر هذا المستخدم أيضًا من الغرفة", "imageUnavailable": "الصورة لم تعد متاحة", "copyImageUrl": "نسخ رابط الصورة", + "copyLink": "نسخ الرابط", + "copyLinkChoose": "نسخ أي رابط؟", "openInBrowser": "فتح في المتصفح", "saveImage": "حفظ الصورة", "videoUnavailable": "الفيديو لم يعد متاحًا", diff --git a/apps/fluux/src/i18n/locales/be.json b/apps/fluux/src/i18n/locales/be.json index 1d7ee0ab..351da6dd 100644 --- a/apps/fluux/src/i18n/locales/be.json +++ b/apps/fluux/src/i18n/locales/be.json @@ -491,6 +491,8 @@ "moderateAndBan": "Таксама забараніць гэтага карыстальніка ў пакоі", "imageUnavailable": "Выява больш недаступная", "copyImageUrl": "Капіяваць спасылку на выяву", + "copyLink": "Капіяваць спасылку", + "copyLinkChoose": "Якую спасылку капіяваць?", "openInBrowser": "Адкрыць у браўзеры", "saveImage": "Захаваць выяву", "videoUnavailable": "Відэа больш недаступнае", diff --git a/apps/fluux/src/i18n/locales/bg.json b/apps/fluux/src/i18n/locales/bg.json index cf12d3ad..df870101 100644 --- a/apps/fluux/src/i18n/locales/bg.json +++ b/apps/fluux/src/i18n/locales/bg.json @@ -489,6 +489,8 @@ "moderateAndBan": "Също забрани този потребител от стаята", "imageUnavailable": "Изображението вече не е налично", "copyImageUrl": "Копиране на връзката към изображението", + "copyLink": "Копиране на връзката", + "copyLinkChoose": "Коя връзка да се копира?", "openInBrowser": "Отваряне в браузъра", "saveImage": "Запазване на изображението", "videoUnavailable": "Видеото вече не е налично", diff --git a/apps/fluux/src/i18n/locales/ca.json b/apps/fluux/src/i18n/locales/ca.json index aa782367..5f772db2 100644 --- a/apps/fluux/src/i18n/locales/ca.json +++ b/apps/fluux/src/i18n/locales/ca.json @@ -492,6 +492,8 @@ "moderateAndBan": "Baneja també aquest usuari de la sala", "imageUnavailable": "La imatge ja no està disponible", "copyImageUrl": "Copiar l'enllaç de la imatge", + "copyLink": "Copiar l'enllaç", + "copyLinkChoose": "Quin enllaç voleu copiar?", "openInBrowser": "Obrir al navegador", "saveImage": "Desar la imatge", "videoUnavailable": "El vídeo ja no està disponible", diff --git a/apps/fluux/src/i18n/locales/cs.json b/apps/fluux/src/i18n/locales/cs.json index bd1bd01e..e996a011 100644 --- a/apps/fluux/src/i18n/locales/cs.json +++ b/apps/fluux/src/i18n/locales/cs.json @@ -493,6 +493,8 @@ "moderateAndBan": "Také zakázat tohoto uživatele v místnosti", "imageUnavailable": "Obrázek již není k dispozici", "copyImageUrl": "Kopírovat odkaz obrázku", + "copyLink": "Kopírovat odkaz", + "copyLinkChoose": "Který odkaz zkopírovat?", "openInBrowser": "Otevřít v prohlížeči", "saveImage": "Uložit obrázek", "videoUnavailable": "Video již není k dispozici", diff --git a/apps/fluux/src/i18n/locales/da.json b/apps/fluux/src/i18n/locales/da.json index d4aca98c..db0f1753 100644 --- a/apps/fluux/src/i18n/locales/da.json +++ b/apps/fluux/src/i18n/locales/da.json @@ -489,6 +489,8 @@ "moderateAndBan": "Udeluk også denne bruger fra rummet", "imageUnavailable": "Billedet er ikke længere tilgængeligt", "copyImageUrl": "Kopiér billedlink", + "copyLink": "Kopiér link", + "copyLinkChoose": "Hvilket link vil du kopiere?", "openInBrowser": "Åbn i browser", "saveImage": "Gem billede", "videoUnavailable": "Videoen er ikke længere tilgængelig", diff --git a/apps/fluux/src/i18n/locales/de.json b/apps/fluux/src/i18n/locales/de.json index ee318da7..4da1f704 100644 --- a/apps/fluux/src/i18n/locales/de.json +++ b/apps/fluux/src/i18n/locales/de.json @@ -491,6 +491,8 @@ "moderateAndBan": "Benutzer auch aus dem Raum verbannen", "imageUnavailable": "Bild nicht mehr verfügbar", "copyImageUrl": "Bild-Link kopieren", + "copyLink": "Link kopieren", + "copyLinkChoose": "Welchen Link kopieren?", "openInBrowser": "Im Browser öffnen", "saveImage": "Bild speichern", "videoUnavailable": "Video nicht mehr verfügbar", diff --git a/apps/fluux/src/i18n/locales/el.json b/apps/fluux/src/i18n/locales/el.json index a389ee3f..b80603e5 100644 --- a/apps/fluux/src/i18n/locales/el.json +++ b/apps/fluux/src/i18n/locales/el.json @@ -490,6 +490,8 @@ "moderateAndBan": "Αποκλεισμός αυτού του χρήστη από το δωμάτιο", "imageUnavailable": "Η εικόνα δεν είναι πλέον διαθέσιμη", "copyImageUrl": "Αντιγραφή συνδέσμου εικόνας", + "copyLink": "Αντιγραφή συνδέσμου", + "copyLinkChoose": "Ποιον σύνδεσμο να αντιγραφεί;", "openInBrowser": "Άνοιγμα στο πρόγραμμα περιήγησης", "saveImage": "Αποθήκευση εικόνας", "videoUnavailable": "Το βίντεο δεν είναι πλέον διαθέσιμο", diff --git a/apps/fluux/src/i18n/locales/en.json b/apps/fluux/src/i18n/locales/en.json index 8a85957e..44761556 100644 --- a/apps/fluux/src/i18n/locales/en.json +++ b/apps/fluux/src/i18n/locales/en.json @@ -489,6 +489,8 @@ "moderateAndBan": "Also ban this user from the room", "imageUnavailable": "Image no longer available", "copyImageUrl": "Copy image URL", + "copyLink": "Copy link", + "copyLinkChoose": "Copy which link?", "openInBrowser": "Open in browser", "saveImage": "Save image", "videoUnavailable": "Video no longer available", diff --git a/apps/fluux/src/i18n/locales/es.json b/apps/fluux/src/i18n/locales/es.json index 283b9d29..0bdd553f 100644 --- a/apps/fluux/src/i18n/locales/es.json +++ b/apps/fluux/src/i18n/locales/es.json @@ -492,6 +492,8 @@ "moderateAndBan": "También expulsar a este usuario de la sala", "imageUnavailable": "Imagen ya no disponible", "copyImageUrl": "Copiar enlace de imagen", + "copyLink": "Copiar enlace", + "copyLinkChoose": "¿Qué enlace copiar?", "openInBrowser": "Abrir en el navegador", "saveImage": "Guardar imagen", "videoUnavailable": "Video ya no disponible", diff --git a/apps/fluux/src/i18n/locales/et.json b/apps/fluux/src/i18n/locales/et.json index 8d242d16..d5f08e8f 100644 --- a/apps/fluux/src/i18n/locales/et.json +++ b/apps/fluux/src/i18n/locales/et.json @@ -491,6 +491,8 @@ "moderateAndBan": "Keela ka see kasutaja ruumist", "imageUnavailable": "Pilt pole enam saadaval", "copyImageUrl": "Kopeeri pildi link", + "copyLink": "Kopeeri link", + "copyLinkChoose": "Millist linki kopeerida?", "openInBrowser": "Ava brauseris", "saveImage": "Salvesta pilt", "videoUnavailable": "Video pole enam saadaval", diff --git a/apps/fluux/src/i18n/locales/fi.json b/apps/fluux/src/i18n/locales/fi.json index c3c2f302..3851b341 100644 --- a/apps/fluux/src/i18n/locales/fi.json +++ b/apps/fluux/src/i18n/locales/fi.json @@ -489,6 +489,8 @@ "moderateAndBan": "Estä myös tämä käyttäjä huoneesta", "imageUnavailable": "Kuva ei ole enää saatavilla", "copyImageUrl": "Kopioi kuvan linkki", + "copyLink": "Kopioi linkki", + "copyLinkChoose": "Mikä linkki kopioidaan?", "openInBrowser": "Avaa selaimessa", "saveImage": "Tallenna kuva", "videoUnavailable": "Video ei ole enää saatavilla", diff --git a/apps/fluux/src/i18n/locales/fr.json b/apps/fluux/src/i18n/locales/fr.json index f3b01b0f..8637b2cc 100644 --- a/apps/fluux/src/i18n/locales/fr.json +++ b/apps/fluux/src/i18n/locales/fr.json @@ -491,6 +491,8 @@ "moderateAndBan": "Bannir également cet utilisateur du salon", "imageUnavailable": "Image non disponible", "copyImageUrl": "Copier le lien de l'image", + "copyLink": "Copier le lien", + "copyLinkChoose": "Copier quel lien ?", "openInBrowser": "Ouvrir dans le navigateur", "saveImage": "Enregistrer l'image", "videoUnavailable": "Vidéo non disponible", diff --git a/apps/fluux/src/i18n/locales/ga.json b/apps/fluux/src/i18n/locales/ga.json index 1c85b092..dfbe62e5 100644 --- a/apps/fluux/src/i18n/locales/ga.json +++ b/apps/fluux/src/i18n/locales/ga.json @@ -492,6 +492,8 @@ "moderateAndBan": "Coisc an t-úsáideoir seo ón seomra freisin", "imageUnavailable": "Níl an íomhá ar fáil a thuilleadh", "copyImageUrl": "Cóipeáil nasc íomhá", + "copyLink": "Cóipeáil nasc", + "copyLinkChoose": "Cén nasc a chóipeáil?", "openInBrowser": "Oscail sa bhrabhsálaí", "saveImage": "Sábháil íomhá", "videoUnavailable": "Níl an físeán ar fáil a thuilleadh", diff --git a/apps/fluux/src/i18n/locales/he.json b/apps/fluux/src/i18n/locales/he.json index 1b695eae..0b077974 100644 --- a/apps/fluux/src/i18n/locales/he.json +++ b/apps/fluux/src/i18n/locales/he.json @@ -490,6 +490,8 @@ "moderateAndBan": "גם חסום את המשתמש מהחדר", "imageUnavailable": "התמונה אינה זמינה יותר", "copyImageUrl": "העתקת קישור תמונה", + "copyLink": "העתקת קישור", + "copyLinkChoose": "איזה קישור להעתיק?", "openInBrowser": "פתיחה בדפדפן", "saveImage": "שמירת תמונה", "videoUnavailable": "הסרטון אינו זמין יותר", diff --git a/apps/fluux/src/i18n/locales/hr.json b/apps/fluux/src/i18n/locales/hr.json index 35c6da69..35d43ed0 100644 --- a/apps/fluux/src/i18n/locales/hr.json +++ b/apps/fluux/src/i18n/locales/hr.json @@ -490,6 +490,8 @@ "moderateAndBan": "Također zabrani ovog korisnika iz sobe", "imageUnavailable": "Slika više nije dostupna", "copyImageUrl": "Kopiraj poveznicu slike", + "copyLink": "Kopiraj poveznicu", + "copyLinkChoose": "Koju poveznicu kopirati?", "openInBrowser": "Otvori u pregledniku", "saveImage": "Spremi sliku", "videoUnavailable": "Video više nije dostupan", diff --git a/apps/fluux/src/i18n/locales/hu.json b/apps/fluux/src/i18n/locales/hu.json index 3c70b4c8..abd4a2e1 100644 --- a/apps/fluux/src/i18n/locales/hu.json +++ b/apps/fluux/src/i18n/locales/hu.json @@ -489,6 +489,8 @@ "moderateAndBan": "A felhasználó kitiltása is a szobából", "imageUnavailable": "A kép már nem érhető el", "copyImageUrl": "Kép linkjének másolása", + "copyLink": "Link másolása", + "copyLinkChoose": "Melyik linket másoljuk?", "openInBrowser": "Megnyitás böngészőben", "saveImage": "Kép mentése", "videoUnavailable": "A videó már nem érhető el", diff --git a/apps/fluux/src/i18n/locales/is.json b/apps/fluux/src/i18n/locales/is.json index 79864d77..98479048 100644 --- a/apps/fluux/src/i18n/locales/is.json +++ b/apps/fluux/src/i18n/locales/is.json @@ -489,6 +489,8 @@ "moderateAndBan": "Banna einnig þennan notanda úr herberginu", "imageUnavailable": "Mynd ekki lengur tiltæk", "copyImageUrl": "Afrita myndatengil", + "copyLink": "Afrita tengil", + "copyLinkChoose": "Hvaða tengil á að afrita?", "openInBrowser": "Opna í vafra", "saveImage": "Vista mynd", "videoUnavailable": "Myndband ekki lengur tiltækt", diff --git a/apps/fluux/src/i18n/locales/it.json b/apps/fluux/src/i18n/locales/it.json index 39b3190a..92cbb885 100644 --- a/apps/fluux/src/i18n/locales/it.json +++ b/apps/fluux/src/i18n/locales/it.json @@ -492,6 +492,8 @@ "moderateAndBan": "Bandisci anche questo utente dalla stanza", "imageUnavailable": "Immagine non più disponibile", "copyImageUrl": "Copia link immagine", + "copyLink": "Copia link", + "copyLinkChoose": "Quale link copiare?", "openInBrowser": "Apri nel browser", "saveImage": "Salva immagine", "videoUnavailable": "Video non più disponibile", diff --git a/apps/fluux/src/i18n/locales/lt.json b/apps/fluux/src/i18n/locales/lt.json index 3dd517e1..3c8237cc 100644 --- a/apps/fluux/src/i18n/locales/lt.json +++ b/apps/fluux/src/i18n/locales/lt.json @@ -491,6 +491,8 @@ "moderateAndBan": "Taip pat uždrausti šiam naudotojui prieigą prie kambario", "imageUnavailable": "Paveikslėlis nebepasiekiamas", "copyImageUrl": "Kopijuoti vaizdo nuorodą", + "copyLink": "Kopijuoti nuorodą", + "copyLinkChoose": "Kurią nuorodą kopijuoti?", "openInBrowser": "Atidaryti naršyklėje", "saveImage": "Išsaugoti vaizdą", "videoUnavailable": "Vaizdo įrašas nebepasiekiamas", diff --git a/apps/fluux/src/i18n/locales/lv.json b/apps/fluux/src/i18n/locales/lv.json index c236d2b8..7bd88f0f 100644 --- a/apps/fluux/src/i18n/locales/lv.json +++ b/apps/fluux/src/i18n/locales/lv.json @@ -491,6 +491,8 @@ "moderateAndBan": "Arī aizliegt šim lietotājam piekļuvi istabai", "imageUnavailable": "Attēls vairs nav pieejams", "copyImageUrl": "Kopēt attēla saiti", + "copyLink": "Kopēt saiti", + "copyLinkChoose": "Kuru saiti kopēt?", "openInBrowser": "Atvērt pārlūkā", "saveImage": "Saglabāt attēlu", "videoUnavailable": "Video vairs nav pieejams", diff --git a/apps/fluux/src/i18n/locales/mt.json b/apps/fluux/src/i18n/locales/mt.json index d3fe05f6..fb77a1df 100644 --- a/apps/fluux/src/i18n/locales/mt.json +++ b/apps/fluux/src/i18n/locales/mt.json @@ -494,6 +494,8 @@ "moderateAndBan": "Ipprojbixxi wkoll dan l-utent mill-kamra", "imageUnavailable": "L-immaġni m'għadhiex disponibbli", "copyImageUrl": "Ikkopja l-link tal-istampa", + "copyLink": "Ikkopja l-link", + "copyLinkChoose": "Liema link tikkopja?", "openInBrowser": "Iftaħ fil-browser", "saveImage": "Issejvja l-istampa", "videoUnavailable": "Il-vidjo m'għadux disponibbli", diff --git a/apps/fluux/src/i18n/locales/nb.json b/apps/fluux/src/i18n/locales/nb.json index f8c187a7..d2dad221 100644 --- a/apps/fluux/src/i18n/locales/nb.json +++ b/apps/fluux/src/i18n/locales/nb.json @@ -489,6 +489,8 @@ "moderateAndBan": "Utesteng også denne brukeren fra rommet", "imageUnavailable": "Bildet er ikke lenger tilgjengelig", "copyImageUrl": "Kopier bildelenke", + "copyLink": "Kopier lenke", + "copyLinkChoose": "Hvilken lenke vil du kopiere?", "openInBrowser": "Åpne i nettleser", "saveImage": "Lagre bilde", "videoUnavailable": "Videoen er ikke lenger tilgjengelig", diff --git a/apps/fluux/src/i18n/locales/nl.json b/apps/fluux/src/i18n/locales/nl.json index e988163c..e4452255 100644 --- a/apps/fluux/src/i18n/locales/nl.json +++ b/apps/fluux/src/i18n/locales/nl.json @@ -489,6 +489,8 @@ "moderateAndBan": "Deze gebruiker ook verbannen uit de kamer", "imageUnavailable": "Afbeelding niet meer beschikbaar", "copyImageUrl": "Afbeeldingslink kopiëren", + "copyLink": "Link kopiëren", + "copyLinkChoose": "Welke link kopiëren?", "openInBrowser": "Openen in browser", "saveImage": "Afbeelding opslaan", "videoUnavailable": "Video niet meer beschikbaar", diff --git a/apps/fluux/src/i18n/locales/pl.json b/apps/fluux/src/i18n/locales/pl.json index 2f75615e..9d1dc0ad 100644 --- a/apps/fluux/src/i18n/locales/pl.json +++ b/apps/fluux/src/i18n/locales/pl.json @@ -493,6 +493,8 @@ "moderateAndBan": "Również zbanuj tego użytkownika z pokoju", "imageUnavailable": "Obraz już niedostępny", "copyImageUrl": "Kopiuj link obrazu", + "copyLink": "Kopiuj link", + "copyLinkChoose": "Który link skopiować?", "openInBrowser": "Otwórz w przeglądarce", "saveImage": "Zapisz obraz", "videoUnavailable": "Film już niedostępny", diff --git a/apps/fluux/src/i18n/locales/pt.json b/apps/fluux/src/i18n/locales/pt.json index 23ea337e..f5adf8a9 100644 --- a/apps/fluux/src/i18n/locales/pt.json +++ b/apps/fluux/src/i18n/locales/pt.json @@ -492,6 +492,8 @@ "moderateAndBan": "Também banir este utilizador da sala", "imageUnavailable": "Imagem já não disponível", "copyImageUrl": "Copiar link da imagem", + "copyLink": "Copiar link", + "copyLinkChoose": "Copiar qual link?", "openInBrowser": "Abrir no navegador", "saveImage": "Guardar imagem", "videoUnavailable": "Vídeo já não disponível", diff --git a/apps/fluux/src/i18n/locales/ro.json b/apps/fluux/src/i18n/locales/ro.json index 0222198e..55c17a35 100644 --- a/apps/fluux/src/i18n/locales/ro.json +++ b/apps/fluux/src/i18n/locales/ro.json @@ -492,6 +492,8 @@ "moderateAndBan": "De asemenea, interzice acestui utilizator accesul în cameră", "imageUnavailable": "Imaginea nu mai este disponibilă", "copyImageUrl": "Copiază linkul imaginii", + "copyLink": "Copiază linkul", + "copyLinkChoose": "Care link se copiază?", "openInBrowser": "Deschide în browser", "saveImage": "Salvează imaginea", "videoUnavailable": "Videoclipul nu mai este disponibil", diff --git a/apps/fluux/src/i18n/locales/ru.json b/apps/fluux/src/i18n/locales/ru.json index 2e19c0b9..873e1f4c 100644 --- a/apps/fluux/src/i18n/locales/ru.json +++ b/apps/fluux/src/i18n/locales/ru.json @@ -491,6 +491,8 @@ "moderateAndBan": "Также забанить этого пользователя в комнате", "imageUnavailable": "Изображение больше недоступно", "copyImageUrl": "Копировать ссылку на изображение", + "copyLink": "Копировать ссылку", + "copyLinkChoose": "Какую ссылку скопировать?", "openInBrowser": "Открыть в браузере", "saveImage": "Сохранить изображение", "videoUnavailable": "Видео больше недоступно", diff --git a/apps/fluux/src/i18n/locales/sk.json b/apps/fluux/src/i18n/locales/sk.json index 1a950c44..88253f03 100644 --- a/apps/fluux/src/i18n/locales/sk.json +++ b/apps/fluux/src/i18n/locales/sk.json @@ -492,6 +492,8 @@ "moderateAndBan": "Tiež zakázať tohto používateľa v miestnosti", "imageUnavailable": "Obrázok už nie je dostupný", "copyImageUrl": "Kopírovať odkaz obrázka", + "copyLink": "Kopírovať odkaz", + "copyLinkChoose": "Ktorý odkaz kopírovať?", "openInBrowser": "Otvoriť v prehliadači", "saveImage": "Uložiť obrázok", "videoUnavailable": "Video už nie je dostupné", diff --git a/apps/fluux/src/i18n/locales/sl.json b/apps/fluux/src/i18n/locales/sl.json index 9e8f3467..6f742939 100644 --- a/apps/fluux/src/i18n/locales/sl.json +++ b/apps/fluux/src/i18n/locales/sl.json @@ -493,6 +493,8 @@ "moderateAndBan": "Tudi prepovej tega uporabnika iz sobe", "imageUnavailable": "Slika ni več na voljo", "copyImageUrl": "Kopiraj povezavo slike", + "copyLink": "Kopiraj povezavo", + "copyLinkChoose": "Katero povezavo kopirati?", "openInBrowser": "Odpri v brskalniku", "saveImage": "Shrani sliko", "videoUnavailable": "Video ni več na voljo", diff --git a/apps/fluux/src/i18n/locales/sv.json b/apps/fluux/src/i18n/locales/sv.json index 16ea9779..a7101d2b 100644 --- a/apps/fluux/src/i18n/locales/sv.json +++ b/apps/fluux/src/i18n/locales/sv.json @@ -489,6 +489,8 @@ "moderateAndBan": "Blockera även denna användare från rummet", "imageUnavailable": "Bilden är inte längre tillgänglig", "copyImageUrl": "Kopiera bildlänk", + "copyLink": "Kopiera länk", + "copyLinkChoose": "Vilken länk vill du kopiera?", "openInBrowser": "Öppna i webbläsare", "saveImage": "Spara bild", "videoUnavailable": "Videon är inte längre tillgänglig", diff --git a/apps/fluux/src/i18n/locales/uk.json b/apps/fluux/src/i18n/locales/uk.json index 939304b3..2bf2935a 100644 --- a/apps/fluux/src/i18n/locales/uk.json +++ b/apps/fluux/src/i18n/locales/uk.json @@ -491,6 +491,8 @@ "moderateAndBan": "Також заблокувати цього користувача в кімнаті", "imageUnavailable": "Зображення більше недоступне", "copyImageUrl": "Копіювати посилання на зображення", + "copyLink": "Копіювати посилання", + "copyLinkChoose": "Яке посилання скопіювати?", "openInBrowser": "Відкрити у браузері", "saveImage": "Зберегти зображення", "videoUnavailable": "Відео більше недоступне", diff --git a/apps/fluux/src/i18n/locales/zh-CN.json b/apps/fluux/src/i18n/locales/zh-CN.json index 701e821d..d6a2301f 100644 --- a/apps/fluux/src/i18n/locales/zh-CN.json +++ b/apps/fluux/src/i18n/locales/zh-CN.json @@ -489,6 +489,8 @@ "moderateAndBan": "同时将此用户从房间中封禁", "imageUnavailable": "图片不再可用", "copyImageUrl": "复制图片链接", + "copyLink": "复制链接", + "copyLinkChoose": "复制哪个链接?", "openInBrowser": "在浏览器中打开", "saveImage": "保存图片", "videoUnavailable": "视频不再可用", diff --git a/apps/fluux/src/test-setup.ts b/apps/fluux/src/test-setup.ts index 649e4e07..92ca5c31 100644 --- a/apps/fluux/src/test-setup.ts +++ b/apps/fluux/src/test-setup.ts @@ -110,6 +110,10 @@ void i18n.use(initReactI18next).init({ newMessagesCount: '{{count}} new message', newMessagesCount_other: '{{count}} new messages', youWereAway: 'You were away', + copyLink: 'Copy link', + copyLinkChoose: 'Copy which link?', + openInBrowser: 'Open in browser', + copyMessage: 'Copy text', }, // Empty state primary actions (ChatLayout EmptyState tests) emptyState: { From 539dd58806fa9bc69f225fc75c768e5f2153e048 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20R=C3=A9mond?= Date: Fri, 10 Jul 2026 15:13:56 +0200 Subject: [PATCH 4/9] refactor(links): extract shared openInBrowser util (#908) --- .../fluux/src/components/ImageContextMenu.tsx | 11 +------- apps/fluux/src/utils/openInBrowser.test.ts | 27 +++++++++++++++++++ apps/fluux/src/utils/openInBrowser.ts | 17 ++++++++++++ 3 files changed, 45 insertions(+), 10 deletions(-) create mode 100644 apps/fluux/src/utils/openInBrowser.test.ts create mode 100644 apps/fluux/src/utils/openInBrowser.ts diff --git a/apps/fluux/src/components/ImageContextMenu.tsx b/apps/fluux/src/components/ImageContextMenu.tsx index 32e7e975..16cad80c 100644 --- a/apps/fluux/src/components/ImageContextMenu.tsx +++ b/apps/fluux/src/components/ImageContextMenu.tsx @@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next' import { Link, ExternalLink, Download } from 'lucide-react' import { MenuButton } from './sidebar-components/SidebarListMenu' import { copyToClipboard } from '@/utils/clipboard' -import { isTauri } from '@/utils/tauri' +import { openInBrowser } from '@/utils/openInBrowser' import { downloadFile } from '@/utils/download' import type { ContextMenuState } from '@/hooks/useContextMenu' import { useFocusTrap } from '@/hooks/useFocusTrap' @@ -14,15 +14,6 @@ interface ImageContextMenuProps { menu: ContextMenuState } -async function openInBrowser(url: string): Promise { - if (isTauri()) { - const { open } = await import('@tauri-apps/plugin-shell') - await open(url) - } else { - window.open(url, '_blank', 'noopener,noreferrer') - } -} - export function ImageContextMenu({ originalUrl, proxiedUrl, filename, menu }: ImageContextMenuProps) { const { t } = useTranslation() diff --git a/apps/fluux/src/utils/openInBrowser.test.ts b/apps/fluux/src/utils/openInBrowser.test.ts new file mode 100644 index 00000000..1e1b0858 --- /dev/null +++ b/apps/fluux/src/utils/openInBrowser.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const openMock = vi.fn() +vi.mock('@tauri-apps/plugin-shell', () => ({ open: openMock })) + +describe('openInBrowser', () => { + beforeEach(() => { + vi.resetModules() + openMock.mockReset() + }) + + it('uses window.open on web', async () => { + vi.doMock('./tauri', () => ({ isTauri: () => false })) + const winOpen = vi.spyOn(window, 'open').mockImplementation(() => null) + const { openInBrowser } = await import('./openInBrowser') + await openInBrowser('https://example.com') + expect(winOpen).toHaveBeenCalledWith('https://example.com', '_blank', 'noopener,noreferrer') + expect(openMock).not.toHaveBeenCalled() + }) + + it('uses the Tauri shell open on desktop', async () => { + vi.doMock('./tauri', () => ({ isTauri: () => true })) + const { openInBrowser } = await import('./openInBrowser') + await openInBrowser('https://example.com') + expect(openMock).toHaveBeenCalledWith('https://example.com') + }) +}) diff --git a/apps/fluux/src/utils/openInBrowser.ts b/apps/fluux/src/utils/openInBrowser.ts new file mode 100644 index 00000000..1c06b866 --- /dev/null +++ b/apps/fluux/src/utils/openInBrowser.ts @@ -0,0 +1,17 @@ +import { isTauri } from './tauri' + +/** + * Open a URL in the user's default browser. + * + * On the Tauri desktop app this hands off to the OS via the shell plugin so the + * link opens in the real browser (not a new WebView window). On web/PWA it falls + * back to `window.open` with `noopener,noreferrer`. + */ +export async function openInBrowser(url: string): Promise { + if (isTauri()) { + const { open } = await import('@tauri-apps/plugin-shell') + await open(url) + } else { + window.open(url, '_blank', 'noopener,noreferrer') + } +} From 9785a5d5f90dd00f34668953cd90c22956eadf45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20R=C3=A9mond?= Date: Fri, 10 Jul 2026 15:18:05 +0200 Subject: [PATCH 5/9] feat(links): add extractLinks helper (#908) --- .../utils/messageStyles.extractLinks.test.ts | 27 +++++++++++++++++++ apps/fluux/src/utils/messageStyles.tsx | 21 +++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 apps/fluux/src/utils/messageStyles.extractLinks.test.ts diff --git a/apps/fluux/src/utils/messageStyles.extractLinks.test.ts b/apps/fluux/src/utils/messageStyles.extractLinks.test.ts new file mode 100644 index 00000000..7288c745 --- /dev/null +++ b/apps/fluux/src/utils/messageStyles.extractLinks.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest' +import { extractLinks } from './messageStyles' + +describe('extractLinks', () => { + it('returns [] when there are no links', () => { + expect(extractLinks('just some text')).toEqual([]) + }) + + it('extracts a single link', () => { + expect(extractLinks('see https://example.com now')).toEqual(['https://example.com']) + }) + + it('extracts multiple links in document order', () => { + expect(extractLinks('a https://a.com b https://b.com')).toEqual([ + 'https://a.com', + 'https://b.com', + ]) + }) + + it('de-duplicates identical links', () => { + expect(extractLinks('https://a.com and again https://a.com')).toEqual(['https://a.com']) + }) + + it('strips trailing sentence punctuation', () => { + expect(extractLinks('go to https://example.com.')).toEqual(['https://example.com']) + }) +}) diff --git a/apps/fluux/src/utils/messageStyles.tsx b/apps/fluux/src/utils/messageStyles.tsx index e31253e2..1bd0c6e8 100644 --- a/apps/fluux/src/utils/messageStyles.tsx +++ b/apps/fluux/src/utils/messageStyles.tsx @@ -27,6 +27,27 @@ import { getConsistentTextColor } from '../components/Avatar' // URL regex pattern - excludes < and > to handle angle-bracketed URLs like const URL_REGEX = /(https?:\/\/[^\s<>]+[^\s<>.,;:!?)"'\]])/g +/** + * Return every http(s) URL found in `text`, in document order, de-duplicated. + * Shares URL_REGEX with the message renderer so "what is a link" stays consistent + * between the rendered text and the copy-link affordances. + */ +export function extractLinks(text: string): string[] { + if (!text) return [] + URL_REGEX.lastIndex = 0 + const seen = new Set() + const out: string[] = [] + let match: RegExpExecArray | null + while ((match = URL_REGEX.exec(text)) !== null) { + const url = match[0] + if (!seen.has(url)) { + seen.add(url) + out.push(url) + } + } + return out +} + // Mention regex pattern: @word (must be preceded by start or whitespace) // Used as fallback when XEP-0372 references aren't available // Uses Unicode property escapes (\p{L} for letters, \p{N} for numbers) to support From 544b3d52defd12ac29e3e1e66c7bb42287f1320c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20R=C3=A9mond?= Date: Fri, 10 Jul 2026 15:20:32 +0200 Subject: [PATCH 6/9] feat(links): add LinkContextMenu popover (#908) --- .../src/components/LinkContextMenu.test.tsx | 48 ++++++++++++++++++ apps/fluux/src/components/LinkContextMenu.tsx | 49 +++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 apps/fluux/src/components/LinkContextMenu.test.tsx create mode 100644 apps/fluux/src/components/LinkContextMenu.tsx diff --git a/apps/fluux/src/components/LinkContextMenu.test.tsx b/apps/fluux/src/components/LinkContextMenu.test.tsx new file mode 100644 index 00000000..4a314fe1 --- /dev/null +++ b/apps/fluux/src/components/LinkContextMenu.test.tsx @@ -0,0 +1,48 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { createRef } from 'react' +import { LinkContextMenu } from './LinkContextMenu' +import type { ContextMenuState } from '@/hooks/useContextMenu' + +const copyMock = vi.fn() +vi.mock('@/utils/clipboard', () => ({ copyToClipboard: (t: string) => copyMock(t) })) +const openMock = vi.fn() +vi.mock('@/utils/openInBrowser', () => ({ openInBrowser: (u: string) => openMock(u) })) + +function makeMenu(isOpen: boolean): ContextMenuState { + return { + isOpen, + position: { x: 10, y: 20 }, + menuRef: createRef(), + longPressTriggered: createRef() as ContextMenuState['longPressTriggered'], + close: vi.fn(), + handleContextMenu: vi.fn(), + handleTouchStart: vi.fn(), + handleTouchEnd: vi.fn(), + } +} + +describe('LinkContextMenu', () => { + beforeEach(() => { + copyMock.mockReset() + openMock.mockReset() + }) + + it('renders nothing when closed', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('copies the link', () => { + render() + fireEvent.click(screen.getByText('Copy link')) + expect(copyMock).toHaveBeenCalledWith('https://x.com') + }) + + it('opens the link in the browser', () => { + render() + fireEvent.click(screen.getByText('Open in browser')) + expect(openMock).toHaveBeenCalledWith('https://x.com') + }) +}) diff --git a/apps/fluux/src/components/LinkContextMenu.tsx b/apps/fluux/src/components/LinkContextMenu.tsx new file mode 100644 index 00000000..1728557a --- /dev/null +++ b/apps/fluux/src/components/LinkContextMenu.tsx @@ -0,0 +1,49 @@ +import { useTranslation } from 'react-i18next' +import { Copy, ExternalLink } from 'lucide-react' +import { MenuButton } from './sidebar-components/SidebarListMenu' +import { copyToClipboard } from '@/utils/clipboard' +import { openInBrowser } from '@/utils/openInBrowser' +import type { ContextMenuState } from '@/hooks/useContextMenu' +import { useFocusTrap } from '@/hooks/useFocusTrap' + +interface LinkContextMenuProps { + url: string + menu: ContextMenuState +} + +/** + * Right-click / long-press menu for a hyperlink in a message. Mirrors + * ImageContextMenu: a small popover positioned at the click point with + * "Copy link" and "Open in browser". Rendered by MessageLink. + */ +export function LinkContextMenu({ url, menu }: LinkContextMenuProps) { + const { t } = useTranslation() + useFocusTrap(menu.menuRef, { active: menu.isOpen }) + + if (!menu.isOpen) return null + + const handleCopy = () => { + menu.close() + void copyToClipboard(url) + } + + const handleOpen = () => { + menu.close() + void openInBrowser(url) + } + + return ( +
+ } label={t('chat.copyLink')} /> + } + label={t('chat.openInBrowser')} + /> +
+ ) +} From f626c535f9e6f4b95e664a3d8545d1617f7be5d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20R=C3=A9mond?= Date: Fri, 10 Jul 2026 15:25:15 +0200 Subject: [PATCH 7/9] feat(links): right-click copy/open via MessageLink (#908) --- .../conversation/MessageLink.test.tsx | 21 ++++++++++ .../components/conversation/MessageLink.tsx | 39 +++++++++++++++++++ apps/fluux/src/utils/messageStyles.tsx | 25 ++---------- 3 files changed, 63 insertions(+), 22 deletions(-) create mode 100644 apps/fluux/src/components/conversation/MessageLink.test.tsx create mode 100644 apps/fluux/src/components/conversation/MessageLink.tsx diff --git a/apps/fluux/src/components/conversation/MessageLink.test.tsx b/apps/fluux/src/components/conversation/MessageLink.test.tsx new file mode 100644 index 00000000..769608b7 --- /dev/null +++ b/apps/fluux/src/components/conversation/MessageLink.test.tsx @@ -0,0 +1,21 @@ +// @vitest-environment jsdom +import { describe, it, expect } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { MessageLink } from './MessageLink' + +describe('MessageLink', () => { + it('renders an anchor to the href', () => { + render() + const a = screen.getByRole('link', { name: 'https://example.com' }) + expect(a).toHaveAttribute('href', 'https://example.com') + expect(a).toHaveAttribute('target', '_blank') + }) + + it('opens the context menu on right-click', () => { + render() + // menu is closed initially + expect(screen.queryByText('Copy link')).toBeNull() + fireEvent.contextMenu(screen.getByRole('link')) + expect(screen.getByText('Copy link')).toBeInTheDocument() + }) +}) diff --git a/apps/fluux/src/components/conversation/MessageLink.tsx b/apps/fluux/src/components/conversation/MessageLink.tsx new file mode 100644 index 00000000..dc3ab523 --- /dev/null +++ b/apps/fluux/src/components/conversation/MessageLink.tsx @@ -0,0 +1,39 @@ +import type { ReactNode } from 'react' +import { createPortal } from 'react-dom' +import { useContextMenu } from '@/hooks/useContextMenu' +import { LinkContextMenu } from '../LinkContextMenu' + +interface MessageLinkProps { + href: string + children?: ReactNode + className?: string +} + +/** + * A hyperlink inside a message. Left-click follows the link (handled globally by + * externalLinkHandler); right-click opens LinkContextMenu (Copy link / Open in + * browser) so the URL can be copied even on packaged desktop builds where the + * native WebView menu is suppressed. + * + * Touch long-press is intentionally NOT wired here: the message bubble already + * owns a long-press that opens MessageActionSheet, which carries its own Copy-link + * affordance. The menu is portalled to document.body so its `position: fixed` + * isn't offset by the virtualizer's row transforms. + */ +export function MessageLink({ href, children, className = 'text-fluux-link hover:underline' }: MessageLinkProps) { + const menu = useContextMenu() + return ( + <> +
+ {children ?? href} + + {menu.isOpen && createPortal(, document.body)} + + ) +} diff --git a/apps/fluux/src/utils/messageStyles.tsx b/apps/fluux/src/utils/messageStyles.tsx index 1bd0c6e8..07ea6e8e 100644 --- a/apps/fluux/src/utils/messageStyles.tsx +++ b/apps/fluux/src/utils/messageStyles.tsx @@ -23,6 +23,7 @@ import { Maximize2 } from 'lucide-react' import { ModalShell } from '../components/ModalShell' import { useHighlighter } from './codeHighlight' import { getConsistentTextColor } from '../components/Avatar' +import { MessageLink } from '../components/conversation/MessageLink' // URL regex pattern - excludes < and > to handle angle-bracketed URLs like const URL_REGEX = /(https?:\/\/[^\s<>]+[^\s<>.,;:!?)"'\]])/g @@ -317,17 +318,7 @@ function renderSegment(segment: StyledSegment, index: number, isDarkMode?: boole ) case 'link': - return ( - - {segment.content} - - ) + return case 'mention': { // Use per-user consistent color when identifier is available, otherwise fall back to brand. // Prefer the caller's resolver (which mirrors the sender-name color, including a roster @@ -647,17 +638,7 @@ export function renderTextWithLinks(text: string): React.ReactNode { // Add the URL as a clickable link const url = match[0] - parts.push( - - {url} - - ) + parts.push() lastIndex = match.index + url.length } From 6b4f0579cb1dfaccf4ed6d1ee810ff6890f15f43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20R=C3=A9mond?= Date: Fri, 10 Jul 2026 15:30:52 +0200 Subject: [PATCH 8/9] feat(links): copy-link row + chooser in message action sheet (#908) --- .../conversation/MessageActionSheet.test.tsx | 43 ++++++++++++++++++- .../conversation/MessageActionSheet.tsx | 39 ++++++++++++++++- 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/apps/fluux/src/components/conversation/MessageActionSheet.test.tsx b/apps/fluux/src/components/conversation/MessageActionSheet.test.tsx index e02cc9ac..f37e9f3b 100644 --- a/apps/fluux/src/components/conversation/MessageActionSheet.test.tsx +++ b/apps/fluux/src/components/conversation/MessageActionSheet.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from 'vitest' +import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' import { MessageActionSheet } from './MessageActionSheet' @@ -14,12 +14,17 @@ vi.mock('react-i18next', () => ({ 'chat.deleteMessage': 'Delete message', 'chat.moreReactions': 'More reactions', 'chat.moreOptions': 'More options', + 'chat.copyLink': 'Copy link', + 'chat.copyLinkChoose': 'Copy which link?', } return map[key] ?? key }, }), })) +const copyMock = vi.fn() +vi.mock('@/utils/clipboard', () => ({ copyToClipboard: (t: string) => copyMock(t) })) + const baseProps = { open: true, onClose: vi.fn(), @@ -82,3 +87,39 @@ describe('MessageActionSheet', () => { expect(onClose).toHaveBeenCalledTimes(1) }) }) + +const linkBaseProps = { + open: true, + onClose: vi.fn(), + myReactions: [] as string[], + onReply: vi.fn(), + onEdit: vi.fn(), + onDelete: vi.fn(), + canReply: false, + canEdit: false, + canDelete: false, +} + +describe('MessageActionSheet copy-link', () => { + beforeEach(() => copyMock.mockReset()) + + it('hides Copy link when the body has no links', () => { + render() + expect(screen.queryByText('Copy link')).toBeNull() + }) + + it('copies the only link directly', () => { + render() + fireEvent.click(screen.getByText('Copy link')) + expect(copyMock).toHaveBeenCalledWith('https://a.com') + }) + + it('opens a chooser when there are several links', () => { + render() + fireEvent.click(screen.getByText('Copy link')) + // chooser header appears; both URLs are listed + expect(screen.getByText('Copy which link?')).toBeInTheDocument() + fireEvent.click(screen.getByText('https://b.com')) + expect(copyMock).toHaveBeenCalledWith('https://b.com') + }) +}) diff --git a/apps/fluux/src/components/conversation/MessageActionSheet.tsx b/apps/fluux/src/components/conversation/MessageActionSheet.tsx index ef992ac9..3c9772f0 100644 --- a/apps/fluux/src/components/conversation/MessageActionSheet.tsx +++ b/apps/fluux/src/components/conversation/MessageActionSheet.tsx @@ -10,10 +10,12 @@ */ import { useState, Suspense, lazy } from 'react' import { useTranslation } from 'react-i18next' -import { Reply, Pencil, Trash2, Copy, SmilePlus } from 'lucide-react' +import { Reply, Pencil, Trash2, Copy, SmilePlus, Link2 } from 'lucide-react' import { BottomSheet } from '../ui/BottomSheet' import { MenuButton, MenuDivider } from '../sidebar-components/SidebarListMenu' import { TOOLBAR_REACTIONS } from './MessageToolbar' +import { extractLinks } from '../../utils/messageStyles' +import { copyToClipboard } from '@/utils/clipboard' // Lazy-load the emoji picker — only fetched when the user opens "more reactions". const EmojiPicker = lazy(() => import('../EmojiPicker').then((m) => ({ default: m.EmojiPicker }))) @@ -50,10 +52,12 @@ export function MessageActionSheet({ }: MessageActionSheetProps) { const { t } = useTranslation() const [showEmojiPicker, setShowEmojiPicker] = useState(false) + const [showLinkPicker, setShowLinkPicker] = useState(false) // Always reset the inner picker on close so the sheet reopens on the actions view. const close = () => { setShowEmojiPicker(false) + setShowLinkPicker(false) onClose() } @@ -74,9 +78,32 @@ export function MessageActionSheet({ const canCopy = !!body + const links = extractLinks(body ?? '') + const copyLink = (url: string) => { + void copyToClipboard(url) + close() + } + const onCopyLinkClick = () => { + if (links.length === 1) copyLink(links[0]) + else setShowLinkPicker(true) + } + return ( - {showEmojiPicker ? ( + {showLinkPicker ? ( +
+
{t('chat.copyLinkChoose')}
+ {links.map((url) => ( + copyLink(url)} + icon={} + label={url} + className="py-3 [&_span]:min-w-0 [&_span]:truncate" + /> + ))} +
+ ) : showEmojiPicker ? (
setShowEmojiPicker(false)} /> @@ -130,6 +157,14 @@ export function MessageActionSheet({ className="py-3" /> )} + {links.length > 0 && ( + } + label={t('chat.copyLink')} + className="py-3" + /> + )} {canEdit && ( runAction(onEdit)} From d91b6bacf4273b9fa353aff0307baa48aa0c1441 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20R=C3=A9mond?= Date: Fri, 10 Jul 2026 15:36:45 +0200 Subject: [PATCH 9/9] docs/i18n(links): clarify extractLinks comment; idiomatic pt copyLinkChoose (#908) --- apps/fluux/src/i18n/locales/pt.json | 2 +- apps/fluux/src/utils/messageStyles.tsx | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/fluux/src/i18n/locales/pt.json b/apps/fluux/src/i18n/locales/pt.json index f5adf8a9..4b7f62fa 100644 --- a/apps/fluux/src/i18n/locales/pt.json +++ b/apps/fluux/src/i18n/locales/pt.json @@ -493,7 +493,7 @@ "imageUnavailable": "Imagem já não disponível", "copyImageUrl": "Copiar link da imagem", "copyLink": "Copiar link", - "copyLinkChoose": "Copiar qual link?", + "copyLinkChoose": "Qual link copiar?", "openInBrowser": "Abrir no navegador", "saveImage": "Guardar imagem", "videoUnavailable": "Vídeo já não disponível", diff --git a/apps/fluux/src/utils/messageStyles.tsx b/apps/fluux/src/utils/messageStyles.tsx index 07ea6e8e..fbbb5156 100644 --- a/apps/fluux/src/utils/messageStyles.tsx +++ b/apps/fluux/src/utils/messageStyles.tsx @@ -30,8 +30,10 @@ const URL_REGEX = /(https?:\/\/[^\s<>]+[^\s<>.,;:!?)"'\]])/g /** * Return every http(s) URL found in `text`, in document order, de-duplicated. - * Shares URL_REGEX with the message renderer so "what is a link" stays consistent - * between the rendered text and the copy-link affordances. + * Shares URL_REGEX with the message renderer so URLs are detected the same way. + * Note: this scans the raw body, so a URL inside an inline-code span or code + * fence (which the renderer shows as non-clickable text) is still returned here + * and remains copyable from the action sheet — an intentional, harmless surplus. */ export function extractLinks(text: string): string[] { if (!text) return []