diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx index e9b8fe0..52cde48 100644 --- a/frontend/src/components/MessageList.tsx +++ b/frontend/src/components/MessageList.tsx @@ -16,7 +16,12 @@ import { formatTime, parseSenderFromText, } from '../utils/messageParser'; -import { giphyUrlForId, parseGif, parseReaction } from '../utils/meshcoreOpenPayloads'; +import { + giphyUrlForId, + parseGif, + parseReaction, + splitReplyMention, +} from '../utils/meshcoreOpenPayloads'; import { useRichPayloads } from '../contexts/RichPayloadContext'; import { formatHopCounts, type SenderInfo } from '../utils/pathUtils'; import { getDirectContactRoute } from '../utils/pathUtils'; @@ -89,20 +94,49 @@ function ReactionPayload({ emoji }: { emoji: string }) { ); } -// Recognize a whole-message MeshCore Open payload and render it. Returns null -// when the content is not a recognized payload, so the caller renders normally. -function renderMeshcoreOpenPayload(content: string): ReactNode | null { - const gifId = parseGif(content); +// Render a bare payload body (no reply prefix) into its rich node, or null. +function renderPayloadBody(body: string): ReactNode | null { + const gifId = parseGif(body); if (gifId) { - return ; + return ; } - const reaction = parseReaction(content); + const reaction = parseReaction(body); if (reaction) { return ; } return null; } +// Recognize a MeshCore Open payload and render it. Handles both a whole-message +// payload ("g:") and a reply-prefixed one ("@[Name] g:") — the form +// meshcore-open sends when a GIF/reaction is a reply, which otherwise renders as +// raw text (issue #291). Returns null when the content is not a recognized +// payload, so the caller renders normally. +function renderMeshcoreOpenPayload( + content: string, + radioName?: string, + onChannelReferenceClick?: (channelName: string) => void +): ReactNode | null { + const whole = renderPayloadBody(content); + if (whole) return whole; + + const split = splitReplyMention(content); + if (split) { + const body = renderPayloadBody(split.body); + if (body) { + // Preserve the reply mention (rendered as a normal @[Name] mention) so the + // GIF/reaction still reads as a reply to that person. + return ( + + {renderTextWithMentions(split.mention, radioName, onChannelReferenceClick)} + {body} + + ); + } + } + return null; +} + // URL regex for linkifying plain text const URL_PATTERN = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/g; @@ -1079,7 +1113,8 @@ export function MessageList({ )}
- {(renderRichPayloads && renderMeshcoreOpenPayload(content)) || + {(renderRichPayloads && + renderMeshcoreOpenPayload(content, radioName, onChannelReferenceClick)) || content.split('\n').map((line, i, arr) => ( {renderTextWithMentions(line, radioName, onChannelReferenceClick)} diff --git a/frontend/src/test/meshcoreOpenPayloads.test.ts b/frontend/src/test/meshcoreOpenPayloads.test.ts index 49be78d..94831d5 100644 --- a/frontend/src/test/meshcoreOpenPayloads.test.ts +++ b/frontend/src/test/meshcoreOpenPayloads.test.ts @@ -10,6 +10,7 @@ import { giphyUrlForId, parseGif, parseReaction, + splitReplyMention, } from '../utils/meshcoreOpenPayloads'; describe('parseGif', () => { @@ -79,3 +80,41 @@ describe('parseReaction', () => { } }); }); + +describe('splitReplyMention', () => { + it('splits a reply-prefixed gif into mention + body (issue #291)', () => { + // meshcore-open sends GIF replies as "@[senderName] g:". + expect(splitReplyMention('@[Alice] g:abc123')).toEqual({ + mention: '@[Alice]', + body: 'g:abc123', + }); + }); + + it('the split body parses as a gif while the whole string does not', () => { + const whole = '@[Alice] g:abc123'; + expect(parseGif(whole)).toBeNull(); // anchored regex rejects the prefix + const split = splitReplyMention(whole); + expect(split && parseGif(split.body)).toBe('abc123'); + }); + + it('splits a reply-prefixed reaction', () => { + expect(splitReplyMention('@[Bob] r:1a2b:00')).toEqual({ + mention: '@[Bob]', + body: 'r:1a2b:00', + }); + }); + + it('trims surrounding whitespace and preserves names with spaces', () => { + expect(splitReplyMention(' @[Node One] g:xy ')).toEqual({ + mention: '@[Node One]', + body: 'g:xy', + }); + }); + + it('returns null without a leading reply mention', () => { + expect(splitReplyMention('g:abc123')).toBeNull(); + expect(splitReplyMention('hello world')).toBeNull(); + expect(splitReplyMention('@[Alice]')).toBeNull(); // mention only, no body + expect(splitReplyMention('text @[Alice] g:abc')).toBeNull(); // not a leading mention + }); +}); diff --git a/frontend/src/utils/meshcoreOpenPayloads.ts b/frontend/src/utils/meshcoreOpenPayloads.ts index 5adacfc..d108b11 100644 --- a/frontend/src/utils/meshcoreOpenPayloads.ts +++ b/frontend/src/utils/meshcoreOpenPayloads.ts @@ -111,3 +111,29 @@ export function parseReaction(text: string): ParsedReaction | null { } return { emoji: REACTION_EMOJIS[index], targetHash: match[1] }; } + +// --- Reply-mention prefix (@[senderName] ) --- + +// meshcore-open prefixes replies with "@[senderName] " before the message body +// (see meshcore-open channels.md / BLE_PROTOCOL.md). Its own display code strips +// that prefix before parsing rich payloads, so a GIF/reaction reply arrives on +// the wire as "@[Name] g:". parseGif/parseReaction stay strict (whole-body +// only); this splits the reply prefix off so the remainder can be parsed. +const REPLY_MENTION_PREFIX = /^(@\[[^\]]+\])\s+([\s\S]+)$/; + +export interface SplitReplyMention { + /** The leading "@[Name]" reply-mention token. */ + mention: string; + /** The message remainder after the reply-mention prefix. */ + body: string; +} + +/** + * Split a leading meshcore-open reply mention ("@[Name] ") off the text, or + * return null when there is no such prefix. + */ +export function splitReplyMention(text: string): SplitReplyMention | null { + const match = REPLY_MENTION_PREFIX.exec(text.trim()); + if (!match) return null; + return { mention: match[1], body: match[2] }; +}