Skip to content
11 changes: 1 addition & 10 deletions apps/fluux/src/components/ImageContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -14,15 +14,6 @@ interface ImageContextMenuProps {
menu: ContextMenuState
}

async function openInBrowser(url: string): Promise<void> {
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()

Expand Down
48 changes: 48 additions & 0 deletions apps/fluux/src/components/LinkContextMenu.test.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(),
longPressTriggered: createRef<boolean>() 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(<LinkContextMenu url="https://x.com" menu={makeMenu(false)} />)
expect(container.firstChild).toBeNull()
})

it('copies the link', () => {
render(<LinkContextMenu url="https://x.com" menu={makeMenu(true)} />)
fireEvent.click(screen.getByText('Copy link'))
expect(copyMock).toHaveBeenCalledWith('https://x.com')
})

it('opens the link in the browser', () => {
render(<LinkContextMenu url="https://x.com" menu={makeMenu(true)} />)
fireEvent.click(screen.getByText('Open in browser'))
expect(openMock).toHaveBeenCalledWith('https://x.com')
})
})
49 changes: 49 additions & 0 deletions apps/fluux/src/components/LinkContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
ref={menu.menuRef}
className="fixed z-50 min-w-[180px] py-1 rounded-lg fluux-popover"
style={{ left: menu.position.x, top: menu.position.y }}
>
<MenuButton onClick={handleCopy} icon={<Copy className="size-4" />} label={t('chat.copyLink')} />
<MenuButton
onClick={handleOpen}
icon={<ExternalLink className="size-4" />}
label={t('chat.openInBrowser')}
/>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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(),
Expand Down Expand Up @@ -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(<MessageActionSheet {...linkBaseProps} body="no links here" />)
expect(screen.queryByText('Copy link')).toBeNull()
})

it('copies the only link directly', () => {
render(<MessageActionSheet {...linkBaseProps} body="visit https://a.com today" />)
fireEvent.click(screen.getByText('Copy link'))
expect(copyMock).toHaveBeenCalledWith('https://a.com')
})

it('opens a chooser when there are several links', () => {
render(<MessageActionSheet {...linkBaseProps} body="https://a.com and https://b.com" />)
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')
})
})
39 changes: 37 additions & 2 deletions apps/fluux/src/components/conversation/MessageActionSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 })))
Expand Down Expand Up @@ -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()
}

Expand All @@ -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 (
<BottomSheet open={open} onClose={close} ariaLabel={t('chat.moreOptions')}>
{showEmojiPicker ? (
{showLinkPicker ? (
<div className="pb-1">
<div className="px-3 py-2 text-sm text-fluux-muted">{t('chat.copyLinkChoose')}</div>
{links.map((url) => (
<MenuButton
key={url}
onClick={() => copyLink(url)}
icon={<Link2 className="size-5 shrink-0" />}
label={url}
className="py-3 [&_span]:min-w-0 [&_span]:truncate"
/>
))}
</div>
) : showEmojiPicker ? (
<div className="flex justify-center px-2 pb-2">
<Suspense fallback={null}>
<EmojiPicker onSelect={react} onClose={() => setShowEmojiPicker(false)} />
Expand Down Expand Up @@ -130,6 +157,14 @@ export function MessageActionSheet({
className="py-3"
/>
)}
{links.length > 0 && (
<MenuButton
onClick={onCopyLinkClick}
icon={<Link2 className="size-5" />}
label={t('chat.copyLink')}
className="py-3"
/>
)}
{canEdit && (
<MenuButton
onClick={() => runAction(onEdit)}
Expand Down
21 changes: 21 additions & 0 deletions apps/fluux/src/components/conversation/MessageLink.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<MessageLink href="https://example.com" />)
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(<MessageLink href="https://example.com" />)
// menu is closed initially
expect(screen.queryByText('Copy link')).toBeNull()
fireEvent.contextMenu(screen.getByRole('link'))
expect(screen.getByText('Copy link')).toBeInTheDocument()
})
})
39 changes: 39 additions & 0 deletions apps/fluux/src/components/conversation/MessageLink.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={className}
onContextMenu={menu.handleContextMenu}
>
{children ?? href}
</a>
{menu.isOpen && createPortal(<LinkContextMenu url={href} menu={menu} />, document.body)}
</>
)
}
2 changes: 2 additions & 0 deletions apps/fluux/src/i18n/locales/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,8 @@
"moderateAndBan": "حظر هذا المستخدم أيضًا من الغرفة",
"imageUnavailable": "الصورة لم تعد متاحة",
"copyImageUrl": "نسخ رابط الصورة",
"copyLink": "نسخ الرابط",
"copyLinkChoose": "نسخ أي رابط؟",
"openInBrowser": "فتح في المتصفح",
"saveImage": "حفظ الصورة",
"videoUnavailable": "الفيديو لم يعد متاحًا",
Expand Down
2 changes: 2 additions & 0 deletions apps/fluux/src/i18n/locales/be.json
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,8 @@
"moderateAndBan": "Таксама забараніць гэтага карыстальніка ў пакоі",
"imageUnavailable": "Выява больш недаступная",
"copyImageUrl": "Капіяваць спасылку на выяву",
"copyLink": "Капіяваць спасылку",
"copyLinkChoose": "Якую спасылку капіяваць?",
"openInBrowser": "Адкрыць у браўзеры",
"saveImage": "Захаваць выяву",
"videoUnavailable": "Відэа больш недаступнае",
Expand Down
2 changes: 2 additions & 0 deletions apps/fluux/src/i18n/locales/bg.json
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,8 @@
"moderateAndBan": "Също забрани този потребител от стаята",
"imageUnavailable": "Изображението вече не е налично",
"copyImageUrl": "Копиране на връзката към изображението",
"copyLink": "Копиране на връзката",
"copyLinkChoose": "Коя връзка да се копира?",
"openInBrowser": "Отваряне в браузъра",
"saveImage": "Запазване на изображението",
"videoUnavailable": "Видеото вече не е налично",
Expand Down
2 changes: 2 additions & 0 deletions apps/fluux/src/i18n/locales/ca.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions apps/fluux/src/i18n/locales/cs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions apps/fluux/src/i18n/locales/da.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions apps/fluux/src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions apps/fluux/src/i18n/locales/el.json
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,8 @@
"moderateAndBan": "Αποκλεισμός αυτού του χρήστη από το δωμάτιο",
"imageUnavailable": "Η εικόνα δεν είναι πλέον διαθέσιμη",
"copyImageUrl": "Αντιγραφή συνδέσμου εικόνας",
"copyLink": "Αντιγραφή συνδέσμου",
"copyLinkChoose": "Ποιον σύνδεσμο να αντιγραφεί;",
"openInBrowser": "Άνοιγμα στο πρόγραμμα περιήγησης",
"saveImage": "Αποθήκευση εικόνας",
"videoUnavailable": "Το βίντεο δεν είναι πλέον διαθέσιμο",
Expand Down
Loading
Loading