feat: code block rendering, syntax highlighting, and compose fixes (#803)

This commit is contained in:
Will Pfleger
2026-06-02 17:43:08 -04:00
committed by GitHub
parent 2349421304
commit 56c504a4c7
18 changed files with 709 additions and 160 deletions

1
.gitignore vendored
View File

@@ -29,6 +29,7 @@ Thumbs.db
playwright-report/
test-results/
blob-report/
.playwright-mcp/
# Node modules (pnpm workspace hoists to root)
node_modules/

View File

@@ -23,6 +23,7 @@ Run `sprout --help` or `sprout <group> --help` for full usage.
## Communication Patterns
- Address agents and humans with plain `@name` — do NOT bold or italicize mention text (formatting prevents alert delivery).
- Message content supports GitHub-flavored Markdown. Use fenced code blocks with a language tag (` ```python `, ` ```typescript `, etc.) for syntax-highlighted rendering on desktop and mobile. Omitting the language tag renders monochrome.
- Use `sprout messages thread` when responding in-thread; post new messages for new topics.
- No push notifications — poll with `sprout messages get --channel <UUID> --since <ts>`. When `since` is set without `before`, results are oldest-first (chronological).

View File

@@ -170,7 +170,8 @@ async fn resolve_content_mentions(
pub struct SendMessageParams {
/// UUID of the channel to post to.
pub channel_id: String,
/// Message body text.
/// Message body text. Supports GitHub-flavored Markdown including fenced code
/// blocks with syntax highlighting.
pub content: String,
/// Nostr event kind. Defaults to KIND_STREAM_MESSAGE (NIP-29 group chat message).
#[serde(default = "default_kind")]
@@ -890,7 +891,9 @@ impl SproutMcpServer {
/// Send a message to a Sprout channel.
#[tool(
name = "send_message",
description = "Send a message to a Sprout channel. Include `parent_event_id` to reply in a thread. \
description = "Send a message to a Sprout channel. Content supports GitHub-flavored Markdown — \
use fenced code blocks with a language tag for syntax-highlighted rendering. \
Include `parent_event_id` to reply in a thread. \
Set `broadcast_to_channel` to also surface the reply in the main channel timeline. \
For forum channels, set `kind` to 45001 (post) or 45003 (comment with `parent_event_id`). \
Default kind is 9 (stream message)."

View File

@@ -74,6 +74,15 @@ Write commands are unaffected. `--format json` (default) returns full fields.
Other kind values are rejected. Use `messages vote --event <id> --direction up|down` to vote on forum posts.
## Message Formatting
Message content is rendered as GitHub-flavored Markdown on both desktop and mobile. Key formatting:
- **Fenced code blocks**: triple-backtick with a language tag for syntax highlighting (190+ languages supported). Omitting the language tag renders a styled monochrome block.
- **Inline code**: single backticks for inline monospace.
- **Mentions**: plain `@name` — do NOT bold or italicize (formatting prevents alert delivery).
- **Links, images, tables, blockquotes, headings**: standard GFM.
## Mem Patch Workflow
For safe concurrent writes, use hash-based conflict detection:

View File

@@ -23,7 +23,20 @@ export function handleCodeFenceEnter(ed: Editor): boolean | undefined {
"",
);
if (FENCE_AT_START.test(textBefore)) return false;
const startMatch = textBefore.match(FENCE_AT_START);
if (startMatch) {
const { tr, schema } = ed.state;
const attrs = startMatch[1] ? { language: startMatch[1] } : {};
tr.delete($cursor.start(), $cursor.pos);
tr.setBlockType(
tr.mapping.map($cursor.start()),
tr.mapping.map($cursor.start()),
schema.nodes.codeBlock,
attrs,
);
ed.view.dispatch(tr);
return true;
}
const m = textBefore.match(FENCE_AFTER_BREAK);
if (!m) return undefined;

View File

@@ -173,6 +173,43 @@
}
}
/* ── Code block line numbers & diff ────────────────────────────────── */
.code-block-lines {
counter-reset: code-line;
}
.code-block-lines [data-line] {
display: block;
counter-increment: code-line;
}
.code-block-lines [data-line]::before {
content: counter(code-line);
display: inline-block;
width: 2.5ch;
margin-right: 1.5ch;
text-align: right;
color: hsl(var(--muted-foreground) / 0.4);
user-select: none;
pointer-events: none;
}
.code-line-diff-add {
background-color: hsl(120 40% 50% / 0.15);
}
.code-line-diff-remove {
background-color: hsl(0 60% 50% / 0.15);
}
.dark .code-line-diff-add {
background-color: hsl(120 40% 50% / 0.12);
}
.dark .code-line-diff-remove {
background-color: hsl(0 60% 50% / 0.12);
}
@layer base {
:root {
/* Catppuccin Latte (mauve accent) */

View File

@@ -9,6 +9,15 @@ import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import { toast } from "sonner";
import {
getSingletonHighlighter,
type HighlighterGeneric,
type BundledLanguage,
type BundledTheme,
type ThemedToken,
} from "shiki";
import { useTheme } from "@/shared/theme/ThemeProvider";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import {
isMessageLink,
@@ -52,6 +61,59 @@ type ImetaEntry = {
type ImetaLookup = Map<string, ImetaEntry>;
let shikiHighlighter: HighlighterGeneric<BundledLanguage, BundledTheme> | null =
null;
let shikiInitPromise: Promise<void> | null = null;
const loadedLangs = new Set<string>();
const loadedThemes = new Set<string>();
const tokenCache = new Map<string, ThemedToken[][]>();
const MAX_CACHE_ENTRIES = 100;
const MAX_LOADED_LANGUAGES = 30;
const MAX_HIGHLIGHT_LINES = 150;
const CODE_BLOCK_CLASS =
"code-block-lines block min-w-full whitespace-pre font-mono text-[13px] leading-6 text-foreground";
const DIFF_ADD_RE = /\s*\/\/\s*\[!code\s*\+\+\]\s*$/;
const DIFF_REMOVE_RE = /\s*\/\/\s*\[!code\s*--\]\s*$/;
function ensureHighlighter(): Promise<void> {
if (shikiHighlighter) return Promise.resolve();
if (!shikiInitPromise) {
shikiInitPromise = getSingletonHighlighter({
themes: [],
langs: [],
}).then((h) => {
shikiHighlighter = h;
});
}
return shikiInitPromise;
}
function extractLanguage(className?: string): string {
if (typeof className !== "string") return "";
const match = className.match(/language-(\S+)/);
return match ? match[1] : "";
}
function stripDiffMarker(tokens: ThemedToken[], marker: RegExp): ThemedToken[] {
const last = tokens[tokens.length - 1];
if (!last) return tokens;
const stripped = last.content.replace(marker, "");
if (stripped === last.content) return tokens;
if (stripped === "") return tokens.slice(0, -1);
return [...tokens.slice(0, -1), { ...last, content: stripped }];
}
function useStableArray<T>(arr: T[]): T[] {
const ref = React.useRef(arr);
if (
arr.length !== ref.current.length ||
arr.some((item, i) => item !== ref.current[i])
) {
ref.current = arr;
}
return ref.current;
}
/**
* `urlTransform` for `<ReactMarkdown>` that preserves `sprout://message?…`
* links. The default transform strips unknown schemes (returns `""`) before
@@ -162,7 +224,13 @@ function getCodeBlockText(children: React.ReactNode) {
return getReactNodeText(children).replace(/\n$/, "");
}
function MarkdownCodeBlock({ children }: { children?: React.ReactNode }) {
function MarkdownCodeBlock({
children,
language,
}: {
children?: React.ReactNode;
language?: string;
}) {
const [isCopying, setIsCopying] = React.useState(false);
const code = React.useMemo(() => getCodeBlockText(children), [children]);
@@ -187,7 +255,12 @@ function MarkdownCodeBlock({ children }: { children?: React.ReactNode }) {
return (
<div className="group relative" data-code-block="">
<pre className="overflow-x-auto rounded-xl border border-border/70 bg-muted/60 px-3 py-1.5 pr-12 shadow-xs">
<pre className="max-h-[400px] overflow-x-auto overflow-y-auto rounded-xl border border-border/70 bg-muted/60 px-3 py-1.5 pr-12 shadow-xs">
{language && (
<div className="mb-1 text-xs text-muted-foreground/70">
{language}
</div>
)}
{children}
</pre>
<Tooltip>
@@ -265,6 +338,139 @@ function FileCard({
);
}
function SyntaxHighlightedCode({
code,
language,
...props
}: {
code: string;
language: string;
} & React.ComponentProps<"code">) {
const { themeName } = useTheme();
const [loadedKey, setLoadedKey] = React.useState(0);
React.useEffect(() => {
let cancelled = false;
async function loadAssets() {
try {
await ensureHighlighter();
if (!shikiHighlighter || cancelled) return;
let loaded = false;
if (!loadedLangs.has(language)) {
if (loadedLangs.size >= MAX_LOADED_LANGUAGES) return;
try {
await shikiHighlighter.loadLanguage(language as BundledLanguage);
loadedLangs.add(language);
loaded = true;
} catch {
return;
}
}
if (!loadedThemes.has(themeName as string)) {
try {
await shikiHighlighter.loadTheme(themeName as BundledTheme);
loadedThemes.add(themeName as string);
loaded = true;
} catch {
return;
}
}
if (loaded && !cancelled) setLoadedKey((k) => k + 1);
} catch {
/* ignore */
}
}
if (!loadedLangs.has(language) || !loadedThemes.has(themeName as string)) {
loadAssets();
}
return () => {
cancelled = true;
};
}, [language, themeName]);
// biome-ignore lint/correctness/useExhaustiveDependencies: loadedKey intentionally triggers re-memoization after async asset loading
const tokens = React.useMemo(() => {
if (
!shikiHighlighter ||
!loadedLangs.has(language) ||
!loadedThemes.has(themeName as string)
)
return null;
if ((code.match(/\n/g) || []).length > MAX_HIGHLIGHT_LINES) return null;
const cacheKey = `${language}:${themeName}:${code}`;
const cached = tokenCache.get(cacheKey);
if (cached) return cached;
try {
const result = shikiHighlighter.codeToTokens(code, {
lang: language as BundledLanguage,
theme: themeName as BundledTheme,
});
if (tokenCache.size >= MAX_CACHE_ENTRIES) {
const firstKey = tokenCache.keys().next().value;
if (firstKey !== undefined) tokenCache.delete(firstKey);
}
tokenCache.set(cacheKey, result.tokens);
return result.tokens;
} catch {
return null;
}
}, [code, language, themeName, loadedKey]);
const codeClassName = CODE_BLOCK_CLASS;
if (!tokens) {
const lines = code.split("\n");
return (
<code {...props} className={codeClassName}>
{lines.map((line, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: lines are positional
<span key={i} data-line="">
{line}
</span>
))}
</code>
);
}
return (
<code {...props} className={codeClassName}>
{tokens.map((line, lineIdx) => {
const lineText = line.map((t) => t.content).join("");
const isAdd = DIFF_ADD_RE.test(lineText);
const isRemove = DIFF_REMOVE_RE.test(lineText);
const diffClass = isAdd
? "code-line-diff-add"
: isRemove
? "code-line-diff-remove"
: undefined;
const renderedTokens =
isAdd || isRemove
? stripDiffMarker(line, isAdd ? DIFF_ADD_RE : DIFF_REMOVE_RE)
: line;
return (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: tokens are positional and never reordered
key={lineIdx}
data-line=""
className={diffClass}
>
{renderedTokens.map((token, tokenIdx) => (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: tokens are positional and never reordered
key={tokenIdx}
style={token.color ? { color: token.color } : undefined}
>
{token.content}
</span>
))}
</span>
);
})}
</code>
);
}
function createMarkdownComponents(
variant: MarkdownVariant,
channels: Channel[],
@@ -360,15 +566,23 @@ function createMarkdownComponents(
typeof className === "string" && className.includes("language-");
if (isFencedCodeBlock || rawCode.endsWith("\n") || code.includes("\n")) {
const language = extractLanguage(className);
if (language) {
return (
<SyntaxHighlightedCode code={code} language={language} {...props} />
);
}
const lines = code.split("\n");
return (
<code
{...props}
className={cn(
"block min-w-full whitespace-pre font-mono text-[13px] leading-6 text-foreground",
className,
)}
>
{code}
<code {...props} className={CODE_BLOCK_CLASS}>
{lines.map((line, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: lines are positional
<span key={i} data-line="">
{line}
</span>
))}
</code>
);
}
@@ -521,12 +735,21 @@ function createMarkdownComponents(
return <p className={paragraphClassName}>{children}</p>;
},
pre: ({ children }) =>
interactive ? (
<MarkdownCodeBlock>{children}</MarkdownCodeBlock>
) : (
<span>{children}</span>
),
pre: ({ children }) => {
if (!interactive) return <span>{children}</span>;
let language = "";
React.Children.forEach(children, (child) => {
if (
React.isValidElement<Record<string, unknown>>(child) &&
typeof child.props?.className === "string"
) {
language = extractLanguage(child.props.className);
}
});
return (
<MarkdownCodeBlock language={language}>{children}</MarkdownCodeBlock>
);
},
strong: ({ children }) => (
<strong className="font-semibold">{children}</strong>
),
@@ -687,7 +910,8 @@ function MarkdownInner({
: compact
? "compact"
: "default";
const { channels } = useChannelNavigation();
const { channels: rawChannels } = useChannelNavigation();
const channels = useStableArray(rawChannels);
const { goChannel } = useAppNavigation();
const components = React.useMemo(

View File

@@ -71,12 +71,12 @@ test("copy a rendered code block and paste it back as code", async ({
await page.keyboard.press("ControlOrMeta+V");
await page.getByTestId("send-message").click();
const copiedCodeBlock = page.locator("pre", { hasText: code });
await expect(copiedCodeBlock).toHaveCount(1);
const codeBlock = page.locator("[data-code-block]");
await expect(codeBlock).toHaveCount(1);
const copyButton = page.getByLabel("Copy code block");
await expect(copyButton).toHaveCSS("opacity", "0");
await copiedCodeBlock.hover();
await codeBlock.hover();
await expect(copyButton).toHaveCSS("opacity", "1");
await copyButton.click();
await expect
@@ -87,7 +87,7 @@ test("copy a rendered code block and paste it back as code", async ({
await page.keyboard.press("ControlOrMeta+V");
await input.press("Enter");
await expect(copiedCodeBlock).toHaveCount(2);
await expect(codeBlock).toHaveCount(2);
});
test("pasting a long copied code block scrolls composer to cursor", async ({
@@ -115,7 +115,7 @@ test("pasting a long copied code block scrolls composer to cursor", async ({
await page.keyboard.press("ControlOrMeta+V");
await page.getByTestId("send-message").click();
const copiedCodeBlock = page.locator("pre", { hasText: longCode });
const copiedCodeBlock = page.locator("[data-code-block]");
await expect(copiedCodeBlock).toHaveCount(1);
await copiedCodeBlock.hover();
await page.getByLabel("Copy code block").click();
@@ -134,6 +134,52 @@ test("pasting a long copied code block scrolls composer to cursor", async ({
.toBeLessThanOrEqual(1);
});
test("code block shows language label when language is specified", async ({
page,
}) => {
await page.context().grantPermissions(["clipboard-read", "clipboard-write"], {
origin: "http://127.0.0.1:4173",
});
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const input = page.getByTestId("message-input");
await page.evaluate(
(text) => navigator.clipboard.writeText(text),
"```typescript\nconst x = 1;\n```",
);
await input.click();
await page.keyboard.press("ControlOrMeta+V");
await page.getByTestId("send-message").click();
const codeBlock = page.locator("[data-code-block]");
await expect(codeBlock).toBeVisible();
await expect(codeBlock.getByText("typescript")).toBeVisible();
});
test("typing triple backticks and Enter creates a code block in composer", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const input = page.getByTestId("message-input");
await input.click();
await page.keyboard.type("```");
await page.keyboard.press("Enter");
// A <pre> code block should appear inside the ProseMirror editor
const editorPre = input.locator("pre");
await expect(editorPre).toBeVisible();
// The literal backticks should be consumed (not visible as text)
await expect(input).not.toContainText("```");
});
test("message input clears after send", async ({ page }) => {
const message = `Clear after send ${Date.now()}`;
const input = page.getByTestId("message-input");

View File

@@ -89,10 +89,12 @@ class ComposeBar extends HookConsumerWidget {
// Typing indicator broadcast — throttled to one event per 3 seconds.
final lastTypingSentMs = useRef(0);
final isModifyingText = useRef(false);
// Detect @mention query and broadcast typing on text / selection change.
useEffect(() {
void listener() {
if (isModifyingText.value) return;
final text = controller.text;
final sel = controller.selection;
@@ -291,51 +293,27 @@ class ComposeBar extends HookConsumerWidget {
final sel = controller.selection;
if (!sel.isValid) return;
if (sel.isCollapsed) {
final offset = sel.baseOffset;
final updated =
'${text.substring(0, offset)}$prefix$suffix${text.substring(offset)}';
controller.text = updated;
controller.selection = TextSelection.collapsed(
offset: offset + prefix.length,
);
} else {
final selected = text.substring(sel.start, sel.end);
final updated =
'${text.substring(0, sel.start)}$prefix$selected$suffix${text.substring(sel.end)}';
controller.text = updated;
controller.selection = TextSelection.collapsed(
offset: sel.start + prefix.length + selected.length + suffix.length,
);
}
focusNode.requestFocus();
}
void applyCodeBlock() {
final text = controller.text;
final sel = controller.selection;
if (!sel.isValid) return;
if (sel.isCollapsed) {
final offset = sel.baseOffset;
const open = '```\n';
const close = '\n```';
final updated =
'${text.substring(0, offset)}$open$close${text.substring(offset)}';
controller.text = updated;
controller.selection = TextSelection.collapsed(
offset: offset + open.length,
);
} else {
final selected = text.substring(sel.start, sel.end);
const open = '```\n';
const close = '\n```';
final updated =
'${text.substring(0, sel.start)}$open$selected$close${text.substring(sel.end)}';
controller.text = updated;
controller.selection = TextSelection.collapsed(
offset: sel.start + open.length + selected.length + close.length,
);
isModifyingText.value = true;
try {
if (sel.isCollapsed) {
final offset = sel.baseOffset;
final updated =
'${text.substring(0, offset)}$prefix$suffix${text.substring(offset)}';
controller.text = updated;
controller.selection = TextSelection.collapsed(
offset: offset + prefix.length,
);
} else {
final selected = text.substring(sel.start, sel.end);
final updated =
'${text.substring(0, sel.start)}$prefix$selected$suffix${text.substring(sel.end)}';
controller.text = updated;
controller.selection = TextSelection.collapsed(
offset: sel.start + prefix.length + selected.length + suffix.length,
);
}
} finally {
isModifyingText.value = false;
}
focusNode.requestFocus();
}
@@ -394,10 +372,7 @@ class ComposeBar extends HookConsumerWidget {
children: [
// Formatting toolbar (toggled via Aa button).
if (showFormatting.value)
_FormattingToolbar(
onFormat: applyFormat,
onCodeBlock: applyCodeBlock,
),
_FormattingToolbar(onFormat: applyFormat),
if (hasAttachments || hasPendingUploads) ...[
_AttachmentStrip(
@@ -852,9 +827,8 @@ class _ChannelSuggestions extends StatelessWidget {
class _FormattingToolbar extends StatelessWidget {
final void Function(String prefix, [String? suffix]) onFormat;
final VoidCallback onCodeBlock;
const _FormattingToolbar({required this.onFormat, required this.onCodeBlock});
const _FormattingToolbar({required this.onFormat});
@override
Widget build(BuildContext context) {
@@ -885,7 +859,7 @@ class _FormattingToolbar extends StatelessWidget {
_FormatButton(
icon: LucideIcons.squareCode,
tooltip: 'Code block',
onTap: onCodeBlock,
onTap: () => onFormat('```\n', '\n```'),
),
],
),

View File

@@ -1,11 +1,14 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gpt_markdown/gpt_markdown.dart';
import 'package:gpt_markdown/custom_widgets/markdown_config.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../shared/clipboard_utils.dart';
import '../../shared/syntax_highlight.dart';
import '../../shared/theme/theme.dart';
import 'media_viewer_page.dart';
import 'message_media.dart';
@@ -15,7 +18,7 @@ const _messageMediaMaxImageHeight = 240.0;
/// Renders message content with markdown formatting, @mentions, #channel links,
/// and media-aware markdown images/videos.
class MessageContent extends StatelessWidget {
class MessageContent extends HookWidget {
final String content;
/// Display names for mentioned pubkeys, extracted from event p-tags.
@@ -51,75 +54,80 @@ class MessageContent extends StatelessWidget {
context.textTheme.bodyMedium?.copyWith(color: context.colors.onSurface);
final imetaByUrl = parseImetaTags(tags);
// Convert autolinks and bare URLs to standard markdown links,
// but skip content inside backticks (inline code / fenced blocks).
final buffer = StringBuffer();
final parts = content.split('`');
for (var i = 0; i < parts.length; i++) {
if (i.isOdd) {
// Inside backticks — preserve as-is.
buffer.write('`${parts[i]}`');
} else {
// 1. Angle-bracket autolinks: <https://...>
var segment = parts[i].replaceAllMapped(
RegExp(r'<(https?://[^>]+)>'),
(m) => '[${m[1]}](${m[1]})',
);
// 2. Bare URLs not already inside markdown link/image syntax.
// Negative lookbehind avoids matching URLs preceded by ]( or =
// which are already part of markdown links or imeta tags.
segment = segment.replaceAllMapped(
RegExp(r'(?<![(\]=])https?://[^\s)>\]]+'),
(m) {
final url = m[0]!;
// Skip if this URL is already a markdown link label that equals
// the URL (produced by step 1 or authored as [url](url)).
final start = m.start;
if (start >= 1 && segment[start - 1] == '[') return url;
return '[$url]($url)';
},
);
buffer.write(segment);
}
}
final processed = buffer.toString();
// Replace spaces with non-breaking spaces inside known mention names
// so the gpt_markdown combined regex can match multi-word names
// even when caseSensitive is not preserved.
// Skip content inside backticks to avoid altering inline code.
final mentionParts = processed.split('`');
final mentionBuf = StringBuffer();
for (var i = 0; i < mentionParts.length; i++) {
if (i.isOdd) {
mentionBuf.write('`${mentionParts[i]}`');
} else {
var segment = mentionParts[i];
for (final name in mentionNames.values) {
if (name.contains(' ')) {
final nbspName = name.replaceAll(' ', '\u00A0');
segment = segment.replaceAllMapped(
RegExp('@${RegExp.escape(name)}', caseSensitive: false),
(m) => '@$nbspName',
);
}
final finalContent = useMemoized(() {
// Convert autolinks and bare URLs to standard markdown links,
// but skip content inside backticks (inline code / fenced blocks).
final buffer = StringBuffer();
final parts = content.split('`');
for (var i = 0; i < parts.length; i++) {
if (i.isOdd) {
// Inside backticks — preserve as-is.
buffer.write('`${parts[i]}`');
} else {
// 1. Angle-bracket autolinks: <https://...>
var segment = parts[i].replaceAllMapped(
RegExp(r'<(https?://[^>]+)>'),
(m) => '[${m[1]}](${m[1]})',
);
// 2. Bare URLs not already inside markdown link/image syntax.
// Negative lookbehind avoids matching URLs preceded by ]( or =
// which are already part of markdown links or imeta tags.
segment = segment.replaceAllMapped(
RegExp(r'(?<![(\]=])https?://[^\s)>\]]+'),
(m) {
final url = m[0]!;
// Skip if this URL is already a markdown link label that equals
// the URL (produced by step 1 or authored as [url](url)).
final start = m.start;
if (start >= 1 && segment[start - 1] == '[') return url;
return '[$url]($url)';
},
);
buffer.write(segment);
}
mentionBuf.write(segment);
}
}
final mentionProcessed = mentionBuf.toString();
final processed = buffer.toString();
// Ensure channel links at the very start of content don't get
// swallowed by markdown processing.
var finalContent = mentionProcessed;
if (RegExp(r'^#[A-Za-z0-9_]').hasMatch(finalContent)) {
finalContent = '\u200B$finalContent';
}
// Replace spaces with non-breaking spaces inside known mention names
// so the gpt_markdown combined regex can match multi-word names
// even when caseSensitive is not preserved.
// Skip content inside backticks to avoid altering inline code.
final mentionParts = processed.split('`');
final mentionBuf = StringBuffer();
for (var i = 0; i < mentionParts.length; i++) {
if (i.isOdd) {
mentionBuf.write('`${mentionParts[i]}`');
} else {
var segment = mentionParts[i];
for (final name in mentionNames.values) {
if (name.contains(' ')) {
final nbspName = name.replaceAll(' ', '\u00A0');
segment = segment.replaceAllMapped(
RegExp('@${RegExp.escape(name)}', caseSensitive: false),
(m) => '@$nbspName',
);
}
}
mentionBuf.write(segment);
}
}
final mentionProcessed = mentionBuf.toString();
// Ensure channel links at the very start of content don't get
// swallowed by markdown processing.
var result = mentionProcessed;
if (RegExp(r'^#[A-Za-z0-9_]').hasMatch(result)) {
result = '\u200B$result';
}
return result;
}, [content, mentionNames]);
return GptMarkdown(
finalContent,
style: style,
followLinkColor: false,
codeBuilder: (context, name, code, closed) =>
_MessageCodeBlock(name: name, code: code),
linkBuilder: (context, linkText, url, linkStyle) =>
_buildLink(context, linkText, url, linkStyle, style),
imageBuilder: (context, imageUrl) =>
@@ -443,6 +451,111 @@ class _MediaPreviewFallback extends StatelessWidget {
}
}
class _MessageCodeBlock extends HookWidget {
final String name;
final String code;
const _MessageCodeBlock({required this.name, required this.code});
@override
Widget build(BuildContext context) {
final isCopied = useState(false);
Future<void> handleCopy() async {
await copyToClipboard(context, code, message: 'Copied code to clipboard');
if (!context.mounted) return;
isCopied.value = true;
Future.delayed(const Duration(seconds: 2), () {
if (context.mounted) isCopied.value = false;
});
}
final codeBaseStyle = TextStyle(
fontFamily: 'GeistMono',
fontSize: 13,
height: 1.5,
color: context.colors.onSurface,
);
final isDark = context.theme.brightness == Brightness.dark;
final codeTheme = isDark ? highlightDarkTheme : highlightLightTheme;
final codeSpans = useMemoized(
() => highlightCode(code, name, codeTheme, codeBaseStyle),
[code, name, isDark],
);
return Container(
margin: const EdgeInsets.only(top: Grid.half),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: context.colors.outline.withValues(alpha: 0.7),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (name.isNotEmpty)
Padding(
padding: const EdgeInsets.only(
left: Grid.twelve,
top: Grid.half + Grid.quarter,
),
child: Text(
name,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
Stack(
children: [
Padding(
padding: EdgeInsets.fromLTRB(
Grid.twelve,
name.isEmpty ? Grid.half + Grid.quarter : Grid.quarter,
44,
Grid.half + Grid.quarter,
),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: RichText(
softWrap: false,
text: TextSpan(style: codeBaseStyle, children: codeSpans),
),
),
),
Positioned(
top: 0,
right: Grid.quarter,
child: SizedBox(
width: 28,
height: 28,
child: IconButton(
onPressed: handleCopy,
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
style: IconButton.styleFrom(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
icon: Icon(
isCopied.value ? LucideIcons.check : LucideIcons.copy,
size: 14,
color: isCopied.value
? context.colors.primary
: context.colors.onSurfaceVariant,
),
),
),
),
],
),
],
),
);
}
}
class _MentionMd extends InlineMd {
final Map<String, String> mentionNames;
late final RegExp _exp = _buildPrefixPattern(

View File

@@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/clipboard_utils.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../../shared/utils/string_utils.dart';
@@ -173,13 +173,11 @@ class UserProfileSheet extends HookConsumerWidget {
color: context.colors.onSurfaceVariant,
fontFamily: 'monospace',
),
onTap: () {
Clipboard.setData(ClipboardData(text: pubkey));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Public key copied'),
duration: Duration(seconds: 2),
),
onTap: () async {
await copyToClipboard(
context,
pubkey,
message: 'Public key copied',
);
},
),

View File

@@ -1,10 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:nostr/nostr.dart' as nostr;
import '../../shared/clipboard_utils.dart';
import '../../shared/theme/theme.dart';
import '../channels/channel_detail_page.dart';
import '../channels/channel_management_provider.dart';
@@ -182,10 +182,11 @@ class NoteCard extends HookConsumerWidget {
),
_ActionButton(
icon: LucideIcons.share,
onTap: () {
Clipboard.setData(ClipboardData(text: _shareUri(note)));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Copied note URI')),
onTap: () async {
await copyToClipboard(
context,
_shareUri(note),
message: 'Copied note URI',
);
},
),

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@@ -7,6 +6,7 @@ import 'package:nostr/nostr.dart' as nostr;
import 'package:package_info_plus/package_info_plus.dart';
import '../../shared/auth/auth.dart';
import '../../shared/clipboard_utils.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/frosted_app_bar.dart';
@@ -76,13 +76,11 @@ class SettingsPage extends HookConsumerWidget {
),
trailing: IconButton(
icon: const Icon(LucideIcons.copy, size: 16),
onPressed: () {
Clipboard.setData(ClipboardData(text: pubkey));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Pubkey copied'),
duration: Duration(seconds: 2),
),
onPressed: () async {
await copyToClipboard(
context,
pubkey,
message: 'Pubkey copied',
);
},
),

View File

@@ -0,0 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
Future<void> copyToClipboard(
BuildContext context,
String text, {
String message = 'Copied to clipboard',
}) async {
await Clipboard.setData(ClipboardData(text: text));
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), duration: const Duration(seconds: 2)),
);
}

View File

@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import 'package:highlight/highlight.dart' show highlight, Node;
const highlightLightTheme = <String, TextStyle>{
'keyword': TextStyle(color: Color(0xFFa626a4)),
'built_in': TextStyle(color: Color(0xFF0184bc)),
'type': TextStyle(color: Color(0xFF0184bc)),
'literal': TextStyle(color: Color(0xFF0184bc)),
'number': TextStyle(color: Color(0xFF986801)),
'string': TextStyle(color: Color(0xFF50a14f)),
'symbol': TextStyle(color: Color(0xFF50a14f)),
'comment': TextStyle(color: Color(0xFFa0a1a7), fontStyle: FontStyle.italic),
'doctag': TextStyle(color: Color(0xFFa0a1a7), fontStyle: FontStyle.italic),
'meta': TextStyle(color: Color(0xFF986801)),
'attr': TextStyle(color: Color(0xFF986801)),
'attribute': TextStyle(color: Color(0xFF986801)),
'title': TextStyle(color: Color(0xFF4078f2)),
'title.class_': TextStyle(color: Color(0xFFc18401)),
'title.function_': TextStyle(color: Color(0xFF4078f2)),
'name': TextStyle(color: Color(0xFFe45649)),
'tag': TextStyle(color: Color(0xFFe45649)),
'selector-tag': TextStyle(color: Color(0xFFe45649)),
'params': TextStyle(color: Color(0xFF383a42)),
'variable': TextStyle(color: Color(0xFFe45649)),
'subst': TextStyle(color: Color(0xFFe45649)),
'section': TextStyle(color: Color(0xFF4078f2)),
'bullet': TextStyle(color: Color(0xFF4078f2)),
'link': TextStyle(color: Color(0xFF4078f2)),
'addition': TextStyle(color: Color(0xFF50a14f)),
'deletion': TextStyle(color: Color(0xFFe45649)),
};
const highlightDarkTheme = <String, TextStyle>{
'keyword': TextStyle(color: Color(0xFFc678dd)),
'built_in': TextStyle(color: Color(0xFF56b6c2)),
'type': TextStyle(color: Color(0xFF56b6c2)),
'literal': TextStyle(color: Color(0xFF56b6c2)),
'number': TextStyle(color: Color(0xFFd19a66)),
'string': TextStyle(color: Color(0xFF98c379)),
'symbol': TextStyle(color: Color(0xFF98c379)),
'comment': TextStyle(color: Color(0xFF5c6370), fontStyle: FontStyle.italic),
'doctag': TextStyle(color: Color(0xFF5c6370), fontStyle: FontStyle.italic),
'meta': TextStyle(color: Color(0xFFd19a66)),
'attr': TextStyle(color: Color(0xFFd19a66)),
'attribute': TextStyle(color: Color(0xFFd19a66)),
'title': TextStyle(color: Color(0xFF61afef)),
'title.class_': TextStyle(color: Color(0xFFe5c07b)),
'title.function_': TextStyle(color: Color(0xFF61afef)),
'name': TextStyle(color: Color(0xFFe06c75)),
'tag': TextStyle(color: Color(0xFFe06c75)),
'selector-tag': TextStyle(color: Color(0xFFe06c75)),
'params': TextStyle(color: Color(0xFFabb2bf)),
'variable': TextStyle(color: Color(0xFFe06c75)),
'subst': TextStyle(color: Color(0xFFe06c75)),
'section': TextStyle(color: Color(0xFF61afef)),
'bullet': TextStyle(color: Color(0xFF61afef)),
'link': TextStyle(color: Color(0xFF61afef)),
'addition': TextStyle(color: Color(0xFF98c379)),
'deletion': TextStyle(color: Color(0xFFe06c75)),
};
List<InlineSpan> highlightCode(
String code,
String language,
Map<String, TextStyle> theme,
TextStyle baseStyle,
) {
try {
if (language.isEmpty) return [TextSpan(text: code, style: baseStyle)];
final result = highlight.parse(code, language: language);
if (result.nodes == null) return [TextSpan(text: code, style: baseStyle)];
return buildSpans(result.nodes!, theme, baseStyle);
} catch (_) {
return [TextSpan(text: code, style: baseStyle)];
}
}
List<InlineSpan> buildSpans(
List<Node> nodes,
Map<String, TextStyle> theme,
TextStyle baseStyle, {
int maxDepth = 10,
}) {
final spans = <InlineSpan>[];
for (final node in nodes) {
if (maxDepth <= 0) {
if (node.value != null) {
spans.add(TextSpan(text: node.value, style: baseStyle));
}
continue;
}
if (node.children != null && node.children!.isNotEmpty) {
final childStyle = node.className != null
? baseStyle.merge(theme[node.className])
: baseStyle;
spans.addAll(
buildSpans(node.children!, theme, childStyle, maxDepth: maxDepth - 1),
);
} else if (node.value != null) {
final style = node.className != null
? baseStyle.merge(theme[node.className])
: baseStyle;
spans.add(TextSpan(text: node.value, style: style));
}
}
return spans;
}

View File

@@ -472,6 +472,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.6"
highlight:
dependency: "direct main"
description:
name: highlight
sha256: "5353a83ffe3e3eca7df0abfb72dcf3fa66cc56b953728e7113ad4ad88497cf21"
url: "https://pub.dev"
source: hosted
version: "0.7.0"
hooks:
dependency: transitive
description:

View File

@@ -22,6 +22,7 @@ dependencies:
pointycastle: ^4.0.0
url_launcher: ^6.3.2
gpt_markdown: ^1.1.6
highlight: ^0.7.0
intl: ^0.20.2
uuid: ^4.5.1
image_picker: ^1.1.2

View File

@@ -254,6 +254,7 @@ void main() {
);
expect(_findRich('void main() {}'), findsWidgets);
expect(find.text('dart'), findsOneWidget);
});
});