diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6d50afb..47b5f09 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -31,6 +31,12 @@ interface ChannelUnreadMarker { lastReadAt: number | null; } +interface NewMessagePrefillRequest { + tab: 'hashtag'; + hashtagName: string; + nonce: number; +} + interface UnreadBoundaryBackfillParams { activeConversation: Conversation | null; unreadMarker: ChannelUnreadMarker | null; @@ -77,6 +83,8 @@ export function App() { const messageInputRef = useRef(null); const [rawPackets, setRawPackets] = useState([]); const [channelUnreadMarker, setChannelUnreadMarker] = useState(null); + const [newMessagePrefillRequest, setNewMessagePrefillRequest] = + useState(null); const [visibilityVersion, setVisibilityVersion] = useState(0); const lastUnreadBackfillAttemptRef = useRef(null); const { @@ -103,8 +111,8 @@ export function App() { setDistanceUnit, handleCloseSettingsView, handleToggleSettingsView, - handleOpenNewMessage, - handleCloseNewMessage, + handleOpenNewMessage: openNewMessageModal, + handleCloseNewMessage: closeNewMessageModal, handleToggleCracker, } = useAppShell(); @@ -413,6 +421,34 @@ export function App() { [fetchUndecryptedCount, setChannels] ); + const handleOpenNewMessage = useCallback(() => { + setNewMessagePrefillRequest(null); + openNewMessageModal(); + }, [openNewMessageModal]); + + const handleCloseNewMessage = useCallback(() => { + setNewMessagePrefillRequest(null); + closeNewMessageModal(); + }, [closeNewMessageModal]); + + const handleChannelReferenceClick = useCallback( + (channelName: string) => { + const existingChannel = channels.find((channel) => channel.name === channelName); + if (existingChannel) { + handleNavigateToChannel(existingChannel.key); + return; + } + + setNewMessagePrefillRequest((previous) => ({ + tab: 'hashtag', + hashtagName: channelName.slice(1), + nonce: (previous?.nonce ?? 0) + 1, + })); + openNewMessageModal(); + }, + [channels, handleNavigateToChannel, openNewMessageModal] + ); + const statusProps = { health, config, @@ -468,6 +504,7 @@ export function App() { onOpenContactInfo: handleOpenContactInfo, onOpenChannelInfo: handleOpenChannelInfo, onSenderClick: handleSenderClick, + onChannelReferenceClick: handleChannelReferenceClick, onLoadOlder: fetchOlderMessages, onResendChannelMessage: handleResendChannelMessage, onTargetReached: () => setTargetMessageId(null), @@ -526,6 +563,7 @@ export function App() { }; const newMessageModalProps = { undecryptedCount, + prefillRequest: newMessagePrefillRequest, onCreateContact: handleCreateContact, onCreateChannel: handleCreateChannel, onCreateHashtagChannel: handleCreateHashtagChannel, diff --git a/frontend/src/components/ConversationPane.tsx b/frontend/src/components/ConversationPane.tsx index aa97aeb..ca7ea9a 100644 --- a/frontend/src/components/ConversationPane.tsx +++ b/frontend/src/components/ConversationPane.tsx @@ -65,6 +65,7 @@ interface ConversationPaneProps { onOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void; onOpenChannelInfo: (channelKey: string) => void; onSenderClick: (sender: string) => void; + onChannelReferenceClick?: (channelName: string) => void; onLoadOlder: () => Promise; onResendChannelMessage: (messageId: number, newTimestamp?: boolean) => Promise; onTargetReached: () => void; @@ -131,6 +132,7 @@ export function ConversationPane({ onOpenContactInfo, onOpenChannelInfo, onSenderClick, + onChannelReferenceClick, onLoadOlder, onResendChannelMessage, onTargetReached, @@ -284,6 +286,7 @@ export function ConversationPane({ activeConversation.type === 'channel' ? onDismissUnreadMarker : undefined } onSenderClick={activeConversation.type === 'channel' ? onSenderClick : undefined} + onChannelReferenceClick={onChannelReferenceClick} onLoadOlder={onLoadOlder} onResendChannelMessage={ activeConversation.type === 'channel' ? onResendChannelMessage : undefined diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx index ea39063..af13a9a 100644 --- a/frontend/src/components/MessageList.tsx +++ b/frontend/src/components/MessageList.tsx @@ -11,7 +11,11 @@ import { import type { Channel, Contact, Message, MessagePath, RadioConfig, RawPacket } from '../types'; import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types'; import { api } from '../api'; -import { formatTime, parseSenderFromText } from '../utils/messageParser'; +import { + findLinkedChannelReferences, + formatTime, + parseSenderFromText, +} from '../utils/messageParser'; import { formatHopCounts, type SenderInfo } from '../utils/pathUtils'; import { getDirectContactRoute } from '../utils/pathUtils'; import { ContactAvatar } from './ContactAvatar'; @@ -33,6 +37,7 @@ interface MessageListProps { onSenderClick?: (sender: string) => void; onLoadOlder?: () => void; onResendChannelMessage?: (messageId: number, newTimestamp?: boolean) => void; + onChannelReferenceClick?: (channelName: string) => void; radioName?: string; config?: RadioConfig | null; onOpenContactInfo?: (publicKey: string, fromChannel?: boolean) => void; @@ -48,8 +53,64 @@ interface MessageListProps { const URL_PATTERN = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/g; -// Helper to convert URLs in a plain text string into clickable links -function linkifyText(text: string, keyPrefix: string): ReactNode[] { +function renderChannelReferences( + text: string, + keyPrefix: string, + onChannelReferenceClick?: (channelName: string) => void +): ReactNode[] { + const references = findLinkedChannelReferences(text); + if (references.length === 0) { + return [text]; + } + + const parts: ReactNode[] = []; + let lastIndex = 0; + + references.forEach((reference, index) => { + if (reference.start > lastIndex) { + parts.push(text.slice(lastIndex, reference.start)); + } + + const className = + 'rounded px-0.5 font-medium text-primary underline underline-offset-2 transition-colors'; + if (onChannelReferenceClick) { + parts.push( + + ); + } else { + parts.push( + + {reference.label} + + ); + } + + lastIndex = reference.end; + }); + + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)); + } + + return parts; +} + +// Helper to convert URLs and channel references in a plain text string into rich content +function linkifyText( + text: string, + keyPrefix: string, + onChannelReferenceClick?: (channelName: string) => void +): ReactNode[] { const parts: ReactNode[] = []; let lastIndex = 0; let match: RegExpExecArray | null; @@ -58,7 +119,13 @@ function linkifyText(text: string, keyPrefix: string): ReactNode[] { URL_PATTERN.lastIndex = 0; while ((match = URL_PATTERN.exec(text)) !== null) { if (match.index > lastIndex) { - parts.push(text.slice(lastIndex, match.index)); + parts.push( + ...renderChannelReferences( + text.slice(lastIndex, match.index), + `${keyPrefix}-text-${keyIndex}`, + onChannelReferenceClick + ) + ); } parts.push( void +): ReactNode { const mentionPattern = /@\[([^\]]+)\]/g; const parts: ReactNode[] = []; let lastIndex = 0; @@ -92,7 +171,13 @@ function renderTextWithMentions(text: string, radioName?: string): ReactNode { while ((match = mentionPattern.exec(text)) !== null) { // Add text before the match (with linkification) if (match.index > lastIndex) { - parts.push(...linkifyText(text.slice(lastIndex, match.index), `pre-${keyIndex}`)); + parts.push( + ...linkifyText( + text.slice(lastIndex, match.index), + `pre-${keyIndex}`, + onChannelReferenceClick + ) + ); } const mentionedName = match[1]; @@ -115,7 +200,7 @@ function renderTextWithMentions(text: string, radioName?: string): ReactNode { // Add remaining text after last match (with linkification) if (lastIndex < text.length) { - parts.push(...linkifyText(text.slice(lastIndex), `post-${keyIndex}`)); + parts.push(...linkifyText(text.slice(lastIndex), `post-${keyIndex}`, onChannelReferenceClick)); } return parts.length > 0 ? parts : text; @@ -188,6 +273,7 @@ export function MessageList({ onSenderClick, onLoadOlder, onResendChannelMessage, + onChannelReferenceClick, radioName, config, onOpenContactInfo, @@ -911,7 +997,7 @@ export function MessageList({
{content.split('\n').map((line, i, arr) => ( - {renderTextWithMentions(line, radioName)} + {renderTextWithMentions(line, radioName, onChannelReferenceClick)} {i < arr.length - 1 &&
}
))} diff --git a/frontend/src/components/NewMessageModal.tsx b/frontend/src/components/NewMessageModal.tsx index bb2d91a..7cb8885 100644 --- a/frontend/src/components/NewMessageModal.tsx +++ b/frontend/src/components/NewMessageModal.tsx @@ -1,4 +1,4 @@ -import { useState, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Dice5 } from 'lucide-react'; import { Dialog, @@ -20,6 +20,11 @@ type Tab = 'new-contact' | 'new-channel' | 'hashtag'; interface NewMessageModalProps { open: boolean; undecryptedCount: number; + prefillRequest?: { + tab: 'hashtag'; + hashtagName: string; + nonce: number; + } | null; onClose: () => void; onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise; onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise; @@ -29,6 +34,7 @@ interface NewMessageModalProps { export function NewMessageModal({ open, undecryptedCount, + prefillRequest = null, onClose, onCreateContact, onCreateChannel, @@ -53,6 +59,24 @@ export function NewMessageModal({ setError(''); }; + useEffect(() => { + if (!open || !prefillRequest) { + return; + } + + setTab(prefillRequest.tab); + setName(prefillRequest.hashtagName); + setContactKey(''); + setChannelKey(''); + setTryHistorical(false); + setPermitCapitals(false); + setError(''); + setLoading(false); + requestAnimationFrame(() => { + hashtagInputRef.current?.focus(); + }); + }, [open, prefillRequest]); + const handleCreate = async () => { setError(''); setLoading(true); diff --git a/frontend/src/test/messageList.test.tsx b/frontend/src/test/messageList.test.tsx index a691a91..ce68d79 100644 --- a/frontend/src/test/messageList.test.tsx +++ b/frontend/src/test/messageList.test.tsx @@ -140,6 +140,59 @@ describe('MessageList channel sender rendering', () => { expect(screen.getByRole('button', { name: 'View info for Alice' })).toBeInTheDocument(); }); + it('renders valid channel references as clickable links and ignores invalid ones', async () => { + const user = userEvent.setup(); + const onChannelReferenceClick = vi.fn(); + + render( + + ); + + const linkedChannel = screen.getByRole('button', { name: '#mesh-room' }); + expect(linkedChannel).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '#bad--room' })).not.toBeInTheDocument(); + expect( + screen.getByRole('link', { name: 'https://example.com/#also-skip' }) + ).toBeInTheDocument(); + + await user.click(linkedChannel); + + expect(onChannelReferenceClick).toHaveBeenCalledWith('#mesh-room'); + }); + + it('links valid channel references in direct messages too', async () => { + const user = userEvent.setup(); + const onChannelReferenceClick = vi.fn(); + + render( + + ); + + await user.click(screen.getByRole('button', { name: '#ops-room' })); + + expect(onChannelReferenceClick).toHaveBeenCalledWith('#ops-room'); + }); + it('renders and dismisses an unread marker at the first unread message boundary', async () => { const user = userEvent.setup(); const messages = [ diff --git a/frontend/src/test/messageParser.test.ts b/frontend/src/test/messageParser.test.ts index cde9690..4304810 100644 --- a/frontend/src/test/messageParser.test.ts +++ b/frontend/src/test/messageParser.test.ts @@ -6,7 +6,12 @@ */ import { describe, it, expect } from 'vitest'; -import { parseSenderFromText, formatTime } from '../utils/messageParser'; +import { + findLinkedChannelReferences, + formatTime, + isValidLinkedChannelName, + parseSenderFromText, +} from '../utils/messageParser'; describe('parseSenderFromText', () => { it('extracts sender and content from "sender: message" format', () => { @@ -95,3 +100,31 @@ describe('formatTime', () => { expect(result).toMatch(/\d{1,2}:\d{2}/); // time portion }); }); + +describe('linked channel references', () => { + it('accepts lowercase alphanumeric names with single dashes', () => { + expect(isValidLinkedChannelName('ops')).toBe(true); + expect(isValidLinkedChannelName('ops-1')).toBe(true); + expect(isValidLinkedChannelName('1-2-3')).toBe(true); + }); + + it('rejects uppercase, leading or trailing dashes, and repeated dashes', () => { + expect(isValidLinkedChannelName('Ops')).toBe(false); + expect(isValidLinkedChannelName('-ops')).toBe(false); + expect(isValidLinkedChannelName('ops-')).toBe(false); + expect(isValidLinkedChannelName('ops--room')).toBe(false); + }); + + it('finds standalone linked channel references in message text', () => { + expect(findLinkedChannelReferences('Join #mesh-room then say hi in #ops2')).toEqual([ + { label: '#mesh-room', start: 5, end: 15 }, + { label: '#ops2', start: 31, end: 36 }, + ]); + }); + + it('ignores invalid or embedded channel-like text', () => { + expect( + findLinkedChannelReferences('skip #Bad #bad--name abc#ops #ops- #opsRoom #ops_room #good-room,') + ).toEqual([]); + }); +}); diff --git a/frontend/src/test/newMessageModal.test.tsx b/frontend/src/test/newMessageModal.test.tsx index d8c8d2e..6bfa287 100644 --- a/frontend/src/test/newMessageModal.test.tsx +++ b/frontend/src/test/newMessageModal.test.tsx @@ -32,7 +32,10 @@ describe('NewMessageModal form reset', () => { vi.clearAllMocks(); }); - function renderModal(open = true) { + function renderModal( + open = true, + overrides: Partial[0]> = {} + ) { return render( { onCreateContact={onCreateContact} onCreateChannel={onCreateChannel} onCreateHashtagChannel={onCreateHashtagChannel} + {...overrides} /> ); } @@ -50,6 +54,26 @@ describe('NewMessageModal form reset', () => { } describe('hashtag tab', () => { + it('prefills the hashtag tab from a linked channel request', async () => { + renderModal(true, { + prefillRequest: { + tab: 'hashtag', + hashtagName: 'mesh-room', + nonce: 1, + }, + }); + + await waitFor(() => { + expect(screen.getByRole('tab', { name: 'Hashtag Channel' })).toHaveAttribute( + 'data-state', + 'active' + ); + }); + expect((screen.getByPlaceholderText('channel-name') as HTMLInputElement).value).toBe( + 'mesh-room' + ); + }); + it('clears name after successful Create', async () => { const user = userEvent.setup(); const { unmount } = renderModal(); diff --git a/frontend/src/utils/messageParser.ts b/frontend/src/utils/messageParser.ts index 9cfe2d1..5fb864a 100644 --- a/frontend/src/utils/messageParser.ts +++ b/frontend/src/utils/messageParser.ts @@ -2,6 +2,9 @@ * Parse sender from channel message text. * Channel messages have format "sender: message". */ +const HASHTAG_CHANNEL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const HASHTAG_CHANNEL_REFERENCE_PATTERN = /(^|\s)(#[a-z0-9]+(?:-[a-z0-9]+)*)(?=$|\s)/g; + export function parseSenderFromText(text: string): { sender: string | null; content: string } { const colonIndex = text.indexOf(': '); if (colonIndex > 0 && colonIndex < 50) { @@ -17,6 +20,35 @@ export function parseSenderFromText(text: string): { sender: string | null; cont return { sender: null, content: text }; } +export interface HashtagChannelReference { + label: string; + start: number; + end: number; +} + +export function isValidLinkedChannelName(name: string): boolean { + return HASHTAG_CHANNEL_NAME_PATTERN.test(name); +} + +export function findLinkedChannelReferences(text: string): HashtagChannelReference[] { + const references: HashtagChannelReference[] = []; + let match: RegExpExecArray | null; + + HASHTAG_CHANNEL_REFERENCE_PATTERN.lastIndex = 0; + while ((match = HASHTAG_CHANNEL_REFERENCE_PATTERN.exec(text)) !== null) { + const prefix = match[1]; + const label = match[2]; + const start = match.index + prefix.length; + references.push({ + label, + start, + end: start + label.length, + }); + } + + return references; +} + /** * Format a Unix timestamp to a time string. * Shows date for messages not from today.