mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-06 18:01:22 +02:00
Add hashtag link detection. Closes #134.
This commit is contained in:
+40
-2
@@ -31,6 +31,12 @@ interface ChannelUnreadMarker {
|
|||||||
lastReadAt: number | null;
|
lastReadAt: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NewMessagePrefillRequest {
|
||||||
|
tab: 'hashtag';
|
||||||
|
hashtagName: string;
|
||||||
|
nonce: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface UnreadBoundaryBackfillParams {
|
interface UnreadBoundaryBackfillParams {
|
||||||
activeConversation: Conversation | null;
|
activeConversation: Conversation | null;
|
||||||
unreadMarker: ChannelUnreadMarker | null;
|
unreadMarker: ChannelUnreadMarker | null;
|
||||||
@@ -77,6 +83,8 @@ export function App() {
|
|||||||
const messageInputRef = useRef<MessageInputHandle>(null);
|
const messageInputRef = useRef<MessageInputHandle>(null);
|
||||||
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
|
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
|
||||||
const [channelUnreadMarker, setChannelUnreadMarker] = useState<ChannelUnreadMarker | null>(null);
|
const [channelUnreadMarker, setChannelUnreadMarker] = useState<ChannelUnreadMarker | null>(null);
|
||||||
|
const [newMessagePrefillRequest, setNewMessagePrefillRequest] =
|
||||||
|
useState<NewMessagePrefillRequest | null>(null);
|
||||||
const [visibilityVersion, setVisibilityVersion] = useState(0);
|
const [visibilityVersion, setVisibilityVersion] = useState(0);
|
||||||
const lastUnreadBackfillAttemptRef = useRef<string | null>(null);
|
const lastUnreadBackfillAttemptRef = useRef<string | null>(null);
|
||||||
const {
|
const {
|
||||||
@@ -103,8 +111,8 @@ export function App() {
|
|||||||
setDistanceUnit,
|
setDistanceUnit,
|
||||||
handleCloseSettingsView,
|
handleCloseSettingsView,
|
||||||
handleToggleSettingsView,
|
handleToggleSettingsView,
|
||||||
handleOpenNewMessage,
|
handleOpenNewMessage: openNewMessageModal,
|
||||||
handleCloseNewMessage,
|
handleCloseNewMessage: closeNewMessageModal,
|
||||||
handleToggleCracker,
|
handleToggleCracker,
|
||||||
} = useAppShell();
|
} = useAppShell();
|
||||||
|
|
||||||
@@ -413,6 +421,34 @@ export function App() {
|
|||||||
[fetchUndecryptedCount, setChannels]
|
[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 = {
|
const statusProps = {
|
||||||
health,
|
health,
|
||||||
config,
|
config,
|
||||||
@@ -468,6 +504,7 @@ export function App() {
|
|||||||
onOpenContactInfo: handleOpenContactInfo,
|
onOpenContactInfo: handleOpenContactInfo,
|
||||||
onOpenChannelInfo: handleOpenChannelInfo,
|
onOpenChannelInfo: handleOpenChannelInfo,
|
||||||
onSenderClick: handleSenderClick,
|
onSenderClick: handleSenderClick,
|
||||||
|
onChannelReferenceClick: handleChannelReferenceClick,
|
||||||
onLoadOlder: fetchOlderMessages,
|
onLoadOlder: fetchOlderMessages,
|
||||||
onResendChannelMessage: handleResendChannelMessage,
|
onResendChannelMessage: handleResendChannelMessage,
|
||||||
onTargetReached: () => setTargetMessageId(null),
|
onTargetReached: () => setTargetMessageId(null),
|
||||||
@@ -526,6 +563,7 @@ export function App() {
|
|||||||
};
|
};
|
||||||
const newMessageModalProps = {
|
const newMessageModalProps = {
|
||||||
undecryptedCount,
|
undecryptedCount,
|
||||||
|
prefillRequest: newMessagePrefillRequest,
|
||||||
onCreateContact: handleCreateContact,
|
onCreateContact: handleCreateContact,
|
||||||
onCreateChannel: handleCreateChannel,
|
onCreateChannel: handleCreateChannel,
|
||||||
onCreateHashtagChannel: handleCreateHashtagChannel,
|
onCreateHashtagChannel: handleCreateHashtagChannel,
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ interface ConversationPaneProps {
|
|||||||
onOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void;
|
onOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void;
|
||||||
onOpenChannelInfo: (channelKey: string) => void;
|
onOpenChannelInfo: (channelKey: string) => void;
|
||||||
onSenderClick: (sender: string) => void;
|
onSenderClick: (sender: string) => void;
|
||||||
|
onChannelReferenceClick?: (channelName: string) => void;
|
||||||
onLoadOlder: () => Promise<void>;
|
onLoadOlder: () => Promise<void>;
|
||||||
onResendChannelMessage: (messageId: number, newTimestamp?: boolean) => Promise<void>;
|
onResendChannelMessage: (messageId: number, newTimestamp?: boolean) => Promise<void>;
|
||||||
onTargetReached: () => void;
|
onTargetReached: () => void;
|
||||||
@@ -131,6 +132,7 @@ export function ConversationPane({
|
|||||||
onOpenContactInfo,
|
onOpenContactInfo,
|
||||||
onOpenChannelInfo,
|
onOpenChannelInfo,
|
||||||
onSenderClick,
|
onSenderClick,
|
||||||
|
onChannelReferenceClick,
|
||||||
onLoadOlder,
|
onLoadOlder,
|
||||||
onResendChannelMessage,
|
onResendChannelMessage,
|
||||||
onTargetReached,
|
onTargetReached,
|
||||||
@@ -284,6 +286,7 @@ export function ConversationPane({
|
|||||||
activeConversation.type === 'channel' ? onDismissUnreadMarker : undefined
|
activeConversation.type === 'channel' ? onDismissUnreadMarker : undefined
|
||||||
}
|
}
|
||||||
onSenderClick={activeConversation.type === 'channel' ? onSenderClick : undefined}
|
onSenderClick={activeConversation.type === 'channel' ? onSenderClick : undefined}
|
||||||
|
onChannelReferenceClick={onChannelReferenceClick}
|
||||||
onLoadOlder={onLoadOlder}
|
onLoadOlder={onLoadOlder}
|
||||||
onResendChannelMessage={
|
onResendChannelMessage={
|
||||||
activeConversation.type === 'channel' ? onResendChannelMessage : undefined
|
activeConversation.type === 'channel' ? onResendChannelMessage : undefined
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ import {
|
|||||||
import type { Channel, Contact, Message, MessagePath, RadioConfig, RawPacket } from '../types';
|
import type { Channel, Contact, Message, MessagePath, RadioConfig, RawPacket } from '../types';
|
||||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
|
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
|
||||||
import { api } from '../api';
|
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 { formatHopCounts, type SenderInfo } from '../utils/pathUtils';
|
||||||
import { getDirectContactRoute } from '../utils/pathUtils';
|
import { getDirectContactRoute } from '../utils/pathUtils';
|
||||||
import { ContactAvatar } from './ContactAvatar';
|
import { ContactAvatar } from './ContactAvatar';
|
||||||
@@ -33,6 +37,7 @@ interface MessageListProps {
|
|||||||
onSenderClick?: (sender: string) => void;
|
onSenderClick?: (sender: string) => void;
|
||||||
onLoadOlder?: () => void;
|
onLoadOlder?: () => void;
|
||||||
onResendChannelMessage?: (messageId: number, newTimestamp?: boolean) => void;
|
onResendChannelMessage?: (messageId: number, newTimestamp?: boolean) => void;
|
||||||
|
onChannelReferenceClick?: (channelName: string) => void;
|
||||||
radioName?: string;
|
radioName?: string;
|
||||||
config?: RadioConfig | null;
|
config?: RadioConfig | null;
|
||||||
onOpenContactInfo?: (publicKey: string, fromChannel?: boolean) => void;
|
onOpenContactInfo?: (publicKey: string, fromChannel?: boolean) => void;
|
||||||
@@ -48,8 +53,64 @@ interface MessageListProps {
|
|||||||
const URL_PATTERN =
|
const URL_PATTERN =
|
||||||
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/g;
|
/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 renderChannelReferences(
|
||||||
function linkifyText(text: string, keyPrefix: string): ReactNode[] {
|
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(
|
||||||
|
<button
|
||||||
|
key={`${keyPrefix}-channel-${index}`}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
className,
|
||||||
|
'inline border-0 bg-transparent p-0 align-baseline hover:text-primary/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
||||||
|
)}
|
||||||
|
onClick={() => onChannelReferenceClick(reference.label)}
|
||||||
|
>
|
||||||
|
{reference.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
parts.push(
|
||||||
|
<span key={`${keyPrefix}-channel-${index}`} className={className}>
|
||||||
|
{reference.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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[] = [];
|
const parts: ReactNode[] = [];
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
let match: RegExpExecArray | null;
|
let match: RegExpExecArray | null;
|
||||||
@@ -58,7 +119,13 @@ function linkifyText(text: string, keyPrefix: string): ReactNode[] {
|
|||||||
URL_PATTERN.lastIndex = 0;
|
URL_PATTERN.lastIndex = 0;
|
||||||
while ((match = URL_PATTERN.exec(text)) !== null) {
|
while ((match = URL_PATTERN.exec(text)) !== null) {
|
||||||
if (match.index > lastIndex) {
|
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(
|
parts.push(
|
||||||
<a
|
<a
|
||||||
@@ -74,15 +141,27 @@ function linkifyText(text: string, keyPrefix: string): ReactNode[] {
|
|||||||
lastIndex = match.index + match[0].length;
|
lastIndex = match.index + match[0].length;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastIndex === 0) return [text];
|
if (lastIndex === 0) {
|
||||||
|
return renderChannelReferences(text, keyPrefix, onChannelReferenceClick);
|
||||||
|
}
|
||||||
if (lastIndex < text.length) {
|
if (lastIndex < text.length) {
|
||||||
parts.push(text.slice(lastIndex));
|
parts.push(
|
||||||
|
...renderChannelReferences(
|
||||||
|
text.slice(lastIndex),
|
||||||
|
`${keyPrefix}-tail`,
|
||||||
|
onChannelReferenceClick
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return parts;
|
return parts;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to render text with highlighted @[Name] mentions and clickable URLs
|
// Helper to render text with highlighted @[Name] mentions and clickable URLs
|
||||||
function renderTextWithMentions(text: string, radioName?: string): ReactNode {
|
function renderTextWithMentions(
|
||||||
|
text: string,
|
||||||
|
radioName?: string,
|
||||||
|
onChannelReferenceClick?: (channelName: string) => void
|
||||||
|
): ReactNode {
|
||||||
const mentionPattern = /@\[([^\]]+)\]/g;
|
const mentionPattern = /@\[([^\]]+)\]/g;
|
||||||
const parts: ReactNode[] = [];
|
const parts: ReactNode[] = [];
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
@@ -92,7 +171,13 @@ function renderTextWithMentions(text: string, radioName?: string): ReactNode {
|
|||||||
while ((match = mentionPattern.exec(text)) !== null) {
|
while ((match = mentionPattern.exec(text)) !== null) {
|
||||||
// Add text before the match (with linkification)
|
// Add text before the match (with linkification)
|
||||||
if (match.index > lastIndex) {
|
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];
|
const mentionedName = match[1];
|
||||||
@@ -115,7 +200,7 @@ function renderTextWithMentions(text: string, radioName?: string): ReactNode {
|
|||||||
|
|
||||||
// Add remaining text after last match (with linkification)
|
// Add remaining text after last match (with linkification)
|
||||||
if (lastIndex < text.length) {
|
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;
|
return parts.length > 0 ? parts : text;
|
||||||
@@ -188,6 +273,7 @@ export function MessageList({
|
|||||||
onSenderClick,
|
onSenderClick,
|
||||||
onLoadOlder,
|
onLoadOlder,
|
||||||
onResendChannelMessage,
|
onResendChannelMessage,
|
||||||
|
onChannelReferenceClick,
|
||||||
radioName,
|
radioName,
|
||||||
config,
|
config,
|
||||||
onOpenContactInfo,
|
onOpenContactInfo,
|
||||||
@@ -911,7 +997,7 @@ export function MessageList({
|
|||||||
<div className="break-words whitespace-pre-wrap">
|
<div className="break-words whitespace-pre-wrap">
|
||||||
{content.split('\n').map((line, i, arr) => (
|
{content.split('\n').map((line, i, arr) => (
|
||||||
<span key={i}>
|
<span key={i}>
|
||||||
{renderTextWithMentions(line, radioName)}
|
{renderTextWithMentions(line, radioName, onChannelReferenceClick)}
|
||||||
{i < arr.length - 1 && <br />}
|
{i < arr.length - 1 && <br />}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Dice5 } from 'lucide-react';
|
import { Dice5 } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -20,6 +20,11 @@ type Tab = 'new-contact' | 'new-channel' | 'hashtag';
|
|||||||
interface NewMessageModalProps {
|
interface NewMessageModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
undecryptedCount: number;
|
undecryptedCount: number;
|
||||||
|
prefillRequest?: {
|
||||||
|
tab: 'hashtag';
|
||||||
|
hashtagName: string;
|
||||||
|
nonce: number;
|
||||||
|
} | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
|
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
|
||||||
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
|
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
|
||||||
@@ -29,6 +34,7 @@ interface NewMessageModalProps {
|
|||||||
export function NewMessageModal({
|
export function NewMessageModal({
|
||||||
open,
|
open,
|
||||||
undecryptedCount,
|
undecryptedCount,
|
||||||
|
prefillRequest = null,
|
||||||
onClose,
|
onClose,
|
||||||
onCreateContact,
|
onCreateContact,
|
||||||
onCreateChannel,
|
onCreateChannel,
|
||||||
@@ -53,6 +59,24 @@ export function NewMessageModal({
|
|||||||
setError('');
|
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 () => {
|
const handleCreate = async () => {
|
||||||
setError('');
|
setError('');
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|||||||
@@ -140,6 +140,59 @@ describe('MessageList channel sender rendering', () => {
|
|||||||
expect(screen.getByRole('button', { name: 'View info for Alice' })).toBeInTheDocument();
|
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 () => {
|
it('renders and dismisses an unread marker at the first unread message boundary', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const messages = [
|
const messages = [
|
||||||
|
|||||||
@@ -6,7 +6,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { parseSenderFromText, formatTime } from '../utils/messageParser';
|
import {
|
||||||
|
findLinkedChannelReferences,
|
||||||
|
formatTime,
|
||||||
|
isValidLinkedChannelName,
|
||||||
|
parseSenderFromText,
|
||||||
|
} from '../utils/messageParser';
|
||||||
|
|
||||||
describe('parseSenderFromText', () => {
|
describe('parseSenderFromText', () => {
|
||||||
it('extracts sender and content from "sender: message" format', () => {
|
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
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -32,7 +32,10 @@ describe('NewMessageModal form reset', () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
function renderModal(open = true) {
|
function renderModal(
|
||||||
|
open = true,
|
||||||
|
overrides: Partial<Parameters<typeof NewMessageModal>[0]> = {}
|
||||||
|
) {
|
||||||
return render(
|
return render(
|
||||||
<NewMessageModal
|
<NewMessageModal
|
||||||
open={open}
|
open={open}
|
||||||
@@ -41,6 +44,7 @@ describe('NewMessageModal form reset', () => {
|
|||||||
onCreateContact={onCreateContact}
|
onCreateContact={onCreateContact}
|
||||||
onCreateChannel={onCreateChannel}
|
onCreateChannel={onCreateChannel}
|
||||||
onCreateHashtagChannel={onCreateHashtagChannel}
|
onCreateHashtagChannel={onCreateHashtagChannel}
|
||||||
|
{...overrides}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -50,6 +54,26 @@ describe('NewMessageModal form reset', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('hashtag tab', () => {
|
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 () => {
|
it('clears name after successful Create', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { unmount } = renderModal();
|
const { unmount } = renderModal();
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
* Parse sender from channel message text.
|
* Parse sender from channel message text.
|
||||||
* Channel messages have format "sender: message".
|
* 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 } {
|
export function parseSenderFromText(text: string): { sender: string | null; content: string } {
|
||||||
const colonIndex = text.indexOf(': ');
|
const colonIndex = text.indexOf(': ');
|
||||||
if (colonIndex > 0 && colonIndex < 50) {
|
if (colonIndex > 0 && colonIndex < 50) {
|
||||||
@@ -17,6 +20,35 @@ export function parseSenderFromText(text: string): { sender: string | null; cont
|
|||||||
return { sender: null, content: text };
|
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.
|
* Format a Unix timestamp to a time string.
|
||||||
* Shows date for messages not from today.
|
* Shows date for messages not from today.
|
||||||
|
|||||||
Reference in New Issue
Block a user