feat: improved Relay Chat with clickable Nomad and LXMF links, and support for basic markdown formatting in messages

This commit is contained in:
Ivan 2026-07-19 06:09:08 -05:00
parent 67e78aa299
commit 38a09762fc
No known key found for this signature in database
6 changed files with 126 additions and 3 deletions

View file

@ -30,6 +30,7 @@ All notable changes to this project will be documented in this file.
- Messages page conversation poll is slower and skips while the tab is hidden
- Schema v51 message-flag backfill skips empty databases so fresh init stays fast
- Relay Chat: denser hub UI, announce interval, collapsed system lines, reconnect notices
- Relay Chat: clickable Nomad and LXMF links plus basic markdown for code, bold, italic, and strikethrough
- Low-memory cleanup and SQLite pragmas under memory pressure
- CI benches use median-of-medians and quieter regression gates
- Backend benchmarks cover slim conversation list, mark-as-read, call history, and missed-call notification paths

Binary file not shown.

View file

@ -1325,6 +1325,8 @@ import { loadRelayLayout, saveRelayLayout } from "../../js/relayLayoutStore.js";
import { loadFeatureSidebarCollapsed, saveFeatureSidebarCollapsed } from "../../js/browserLayoutStore.js";
import { RELAY_HOST_MODAL_OVERLAY, RELAY_HOST_MODAL_PANEL_COMPACT } from "../../js/relayHostModalClasses.js";
import { buildRelayShareMessage } from "../../js/relayLinkUtils.js";
import { handleRichHtmlLinkClick } from "../../js/NomadRichHtmlLinks.js";
import MarkdownRenderer from "../../js/MarkdownRenderer.js";
import {
ANNOUNCE_SLIDER_POS_MAX,
announceMinutesToSliderPos,
@ -2407,6 +2409,33 @@ export default {
return "";
}
},
renderMessageHtml(text) {
return MarkdownRenderer.renderBasic(text);
},
handleMessageHtmlClick(event) {
const hex32 = /^[a-fA-F0-9]{32}$/;
const routeName = this.$route?.meta?.isPopout ? "nomadnetwork-popout" : "nomadnetwork";
handleRichHtmlLinkClick(event, {
onNomadUrl: (url) => {
const [hash, ...pathParts] = url.split(":");
const path = pathParts.join(":");
if (!hex32.test(hash)) {
return;
}
this.$router.push({
name: routeName,
params: { destinationHash: hash },
query: { path },
});
},
onLxmfAddress: (address) => {
this.$router.push({
name: "messages",
params: { destinationHash: address },
});
},
});
},
formatHash(hash) {
if (!hash) {
return "-";

View file

@ -54,7 +54,12 @@
:data-msg-key="page.messageKey(entry.msg)"
>
<span class="mr-1 text-xs text-sem-fg-muted">{{ page.formatTime(entry.msg.ts) }}</span>
* {{ page.displayName(entry.msg) }} {{ entry.msg.text }}
* {{ page.displayName(entry.msg) }}
<span
class="break-words"
v-html="page.renderMessageHtml(entry.msg.text)"
@click="page.handleMessageHtmlClick($event)"
></span>
</div>
<div
v-else-if="entry.msg"
@ -65,7 +70,11 @@
>
<span class="mr-1.5 text-xs text-sem-fg-muted">{{ page.formatTime(entry.msg.ts) }}</span>
<span class="mr-1.5 font-semibold" :style="page.nameStyle(entry.msg)">{{ page.displayName(entry.msg) }}:</span>
<span class="whitespace-pre-wrap break-words">{{ entry.msg.text }}</span>
<span
class="whitespace-pre-wrap break-words"
v-html="page.renderMessageHtml(entry.msg.text)"
@click="page.handleMessageHtmlClick($event)"
></span>
</div>
</template>

View file

@ -85,6 +85,48 @@ export default class MarkdownRenderer {
return processed_parts.join("\n");
}
/**
* Minimal markdown for chat surfaces (RRC): links, inline code, bold, italic, strikethrough.
* Links run before inline code so Nomad field data after a single backtick stays intact.
*/
static renderBasic(text) {
if (text == null) {
return "";
}
if (typeof text !== "string") {
text = String(text);
}
text = Utils.escapeHtml(text);
text = LinkUtils.renderAllLinks(text);
const { protectedText, anchors } = LinkUtils.protectAnchors(text);
text = protectedText;
const inline_codes = [];
const pushInline = (code) => {
const placeholder = `[[IC${inline_codes.length}]]`;
inline_codes.push(
`<code class="bg-black/10 dark:bg-white/10 px-1 rounded-sm font-mono text-[0.9em]">${code}</code>`
);
return placeholder;
};
text = text.replace(/``([^`]+)``/g, (_m, code) => pushInline(code));
text = text.replace(/`([^`]+)`/g, (_m, code) => pushInline(code));
text = text.replace(/~~(.*?)~~/g, "<del>$1</del>");
text = text.replace(/\*\*\*(.*?)\*\*\*/g, "<strong><em>$1</em></strong>");
text = text.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>");
text = text.replace(/\*(.*?)\*/g, "<em>$1</em>");
text = text.replace(/(^|[^\w])___(.*?)___(?=[^\w]|$)/g, "$1<strong><em>$2</em></strong>");
text = text.replace(/(^|[^\w])__(.*?)__(?=[^\w]|$)/g, "$1<strong>$2</strong>");
text = text.replace(/(^|[^\w])_(.*?)_(?=[^\w]|$)/g, "$1<em>$2</em>");
text = text.replace(/\[\[IC(\d+)\]\]/g, (_m, idx) => inline_codes[Number(idx)] ?? _m);
text = LinkUtils.restoreAnchors(text, anchors);
return text.replace(/\n/g, "<br>");
}
/**
* True when the body is only a single emoji (after markdown strip), for large bubble rendering.
*/
@ -131,7 +173,8 @@ export default class MarkdownRenderer {
// Strip headers
text = text.replace(/^#+ (.*)$/gm, "$1");
// Strip bold and italic
// Strip strikethrough, bold and italic
text = text.replace(/~~(.*?)~~/g, "$1");
text = text.replace(/\*\*\*(.*?)\*\*\*/g, "$1");
text = text.replace(/\*\*(.*?)\*\*/g, "$1");
text = text.replace(/\*(.*?)\*/g, "$1");

View file

@ -527,3 +527,44 @@ describe("MarkdownRenderer.js", () => {
});
});
});
describe("renderBasic (RRC / limited chat markdown)", () => {
it("renders bold, italic, strikethrough, and inline code", () => {
const result = MarkdownRenderer.renderBasic("**bold** *italic* ~~gone~~ `code`");
expect(result).toContain("<strong>bold</strong>");
expect(result).toContain("<em>italic</em>");
expect(result).toContain("<del>gone</del>");
expect(result).toContain("<code");
expect(result).toContain("code");
});
it("does not render headers or blockquotes", () => {
const result = MarkdownRenderer.renderBasic("# Title\n> quote");
expect(result).not.toContain("<h1");
expect(result).not.toContain("blockquote");
expect(result).toContain("# Title");
});
it("keeps Nomad path field data after a single backtick as one link", () => {
const text =
"9ce92808be498e9e05590ff27cbfdfe4:/page/forum/thread.mu`cat=general|thread=critical-security-update-rns-139-fixes-severe-rnsh-security-flaw";
const result = MarkdownRenderer.renderBasic(text);
expect(result).toContain("nomadnet-link");
expect(result).toContain(
'data-nomadnet-url="9ce92808be498e9e05590ff27cbfdfe4:/page/forum/thread.mu`cat=general|thread=critical-security-update-rns-139-fixes-severe-rnsh-security-flaw"'
);
expect(result).not.toContain("<code");
});
it("renders https links and escapes HTML", () => {
const result = MarkdownRenderer.renderBasic('hi <script>alert(1)</script> https://example.com/a');
expect(result).not.toContain("<script>");
expect(result).toContain("&lt;script&gt;");
expect(result).toContain('href="https://example.com/a"');
});
it("preserves line breaks as br tags", () => {
const result = MarkdownRenderer.renderBasic("line1\nline2");
expect(result).toBe("line1<br>line2");
});
});