Fix giphy rendering issue. Closes #291.

This commit is contained in:
Jack Kingsman
2026-07-08 17:23:51 -07:00
parent 4385ea5703
commit f9b991b1a8
3 changed files with 108 additions and 8 deletions
+43 -8
View File
@@ -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 <GifPayload gifId={gifId} rawText={content} />;
return <GifPayload gifId={gifId} rawText={body} />;
}
const reaction = parseReaction(content);
const reaction = parseReaction(body);
if (reaction) {
return <ReactionPayload emoji={reaction.emoji} />;
}
return null;
}
// Recognize a MeshCore Open payload and render it. Handles both a whole-message
// payload ("g:<id>") and a reply-prefixed one ("@[Name] g:<id>") — 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 (
<span className="inline-flex flex-wrap items-center gap-1.5">
{renderTextWithMentions(split.mention, radioName, onChannelReferenceClick)}
{body}
</span>
);
}
}
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({
</div>
)}
<div className="break-words whitespace-pre-wrap">
{(renderRichPayloads && renderMeshcoreOpenPayload(content)) ||
{(renderRichPayloads &&
renderMeshcoreOpenPayload(content, radioName, onChannelReferenceClick)) ||
content.split('\n').map((line, i, arr) => (
<span key={i}>
{renderTextWithMentions(line, radioName, onChannelReferenceClick)}
@@ -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:<id>".
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
});
});
@@ -111,3 +111,29 @@ export function parseReaction(text: string): ParsedReaction | null {
}
return { emoji: REACTION_EMOJIS[index], targetHash: match[1] };
}
// --- Reply-mention prefix (@[senderName] <payload>) ---
// 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:<id>". 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] };
}