- {(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] };
+}