Add hashtag link detection. Closes #134.

This commit is contained in:
Jack Kingsman
2026-03-31 12:55:52 -07:00
parent 29e9a5f701
commit 9f4737d350
8 changed files with 308 additions and 15 deletions
+53
View File
@@ -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(
<MessageList
messages={[
createMessage({
text: 'Alice: Join #mesh-room now skip #bad--room and visit https://example.com/#also-skip',
}),
]}
contacts={[]}
loading={false}
onChannelReferenceClick={onChannelReferenceClick}
/>
);
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(
<MessageList
messages={[
createMessage({
type: 'PRIV',
text: 'check #ops-room',
conversation_key: 'ab'.repeat(32),
}),
]}
contacts={[]}
loading={false}
onChannelReferenceClick={onChannelReferenceClick}
/>
);
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 = [
+34 -1
View File
@@ -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([]);
});
});
+25 -1
View File
@@ -32,7 +32,10 @@ describe('NewMessageModal form reset', () => {
vi.clearAllMocks();
});
function renderModal(open = true) {
function renderModal(
open = true,
overrides: Partial<Parameters<typeof NewMessageModal>[0]> = {}
) {
return render(
<NewMessageModal
open={open}
@@ -41,6 +44,7 @@ describe('NewMessageModal form reset', () => {
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();