9b2d034d2f Add markdown rendering for chat messages (#665)
* Bridge whitenoise-markdown AST through flutter-rust-bridge

Bumps the whitenoise dep to 53fbbdd, which replaces the
`Vec<SerializableToken>` content representation with a
`whitenoise_markdown::Document` (CommonMark + GFM + nostr-extension
AST). Field names `tokens` / `content_tokens` are retained for FFI
continuity as the upstream author signaled.

- rust/src/api/markdown.rs: new module mirroring every AST node
  (Document, Block, Inline, ListItem, ListKind, Alignment,
  CodeBlockKind, AutolinkKind, NostrEntity, NostrHrp, TableCell) as
  FRB-friendly types, with From impls and 24 unit tests.
- rust/src/api/messages.rs: SerializableToken removed; tokens and
  content_tokens retyped to MarkdownDocument.
- rust/src/api/groups.rs: add disappearing_message_secs: None to
  NostrGroupConfigData / NostrGroupDataUpdate (incidental upstream
  change pulled in by the rev bump).
- Regenerated FRB bindings — recursive AST bridged cleanly as Dart
  sealed classes.
- lib/screens/chat_raw_debug_screen.dart: rewrote the token-dump
  section as a recursive AST pretty-printer.
- 12 test fixtures: tokens/contentTokens fixtures retyped.

No new rendering yet — content is still rendered as plain text by
WnMessageBubble; the parsed AST sits unused on ChatMessage until the
renderer lands in the next commit.

* Render markdown in chat message bubbles

Wires the parsed whitenoise_markdown::Document AST through to a new
WnMarkdownText widget that renders inside ChatMessageBubble.

Renderer (lib/widgets/wn_markdown_text.dart, 480 lines):
- Faithful AST walk: every Block (Paragraph, Heading, ThematicBreak,
  CodeBlock, BlockQuote, List, Table, MathBlock) and every Inline
  (Text, SoftBreak, HardBreak, Code, Emph, Strong, Strikethrough,
  Link, Image, Autolink, Math, NostrMention, NostrUri) renders.
- Task-list checkboxes for ListItem.checked = Some(...).
- Table with per-column alignment, scrolls horizontally on overflow.
- Inline images render as tappable '[image: alt]' text — no
  in-bubble network image loads.
- Math: source rendered as italic monospace (no LaTeX engine).
- URL scheme allow-list: http, https, mailto, nostr, tel. Anything
  else (javascript:, data:, file:, ...) gets no tap recognizer.
- Highlight-query support: per-inline substring matching merged with
  AST rendering, so search results show both markdown formatting and
  highlight backgrounds in the same view.
- HookWidget manages TapGestureRecognizer lifetimes via useRef +
  useEffect cleanup (no StatefulWidget).

Integration (lib/widgets/wn_message_bubble.dart, chat_message_bubble.dart):
- New `document` parameter on WnMessageBubble; `onLinkTap` /
  `onNostrTap` callbacks.
- Plain-text fast path: when the document is empty, or is a single
  Paragraph whose inlines are only Text/SoftBreak/HardBreak, the
  existing Text widget is used — plain messages render byte-identically
  to before, preserving the inline-timestamp layout in _TextWithTimestamp.
- Formatted messages route through WnMarkdownText; timestamp moves to
  its own row below the content.
- Deleted messages ignore the document.
- ChatMessageBubble provides default tap handlers using url_launcher
  (LaunchMode.externalApplication), guarded by isSafeMarkdownUrl.

Tests:
- test/widgets/wn_markdown_text_test.dart (51 widget tests) covers
  every Block and Inline variant, URL safety, highlight merging,
  maxLines truncation, empty / edge cases.
- test/widgets/wn_message_bubble_test.dart: 8 new tests for the
  plain-text / markdown routing and callback wiring.
- test/widgets/chat_message_bubble_test.dart: 3 new tests verifying
  url_launcher integration (safe URL launches externally, javascript:
  is silently dropped, nostr: URIs launch).
- All 4487 tests pass. Coverage: 99.04%.

Known limitations (worth surfacing in QA):
- Chat-list previews, reply quotes, and notifications still render
  the raw message string — markdown syntax characters will appear
  as literal text in those surfaces. This matches what most chat
  apps do and Whitenoise gives us plain content there anyway.
- Multi-block markdown messages move the timestamp to its own row;
  plain-text and single-formatted-paragraph messages keep the inline
  timestamp behavior.

* Render SoftBreak as newline so user-typed line breaks survive

The CommonMark default for SoftBreak is a space, which is fine on the
web where authors hard-wrap source for readability and expect rendering
to reflow. In a chat composer every Enter press produces an in-paragraph
newline, which the parser emits as SoftBreak — so the default would
collapse multi-line messages into one long visual line.

This matches what every chat app does (Slack, Telegram, Discord,
iMessage all map SoftBreak → newline). HardBreak already mapped to
newline; the two now share an arm.

The image-alt flattening helper (_flattenInlines) still emits a space
for SoftBreak — alt text shouldn't carry hard line breaks.

* Render @npub mentions as underlined display name

Per Vlad's design call: Nostr npub mentions should appear as the
mentioned user's display name with an underline, not as a chip and not
in link-blue. Other Nostr HRPs (note/nevent/nprofile/naddr/nrelay)
still render with the link-style fallback since they point to events,
not people.

- WnMarkdownText: new `mentionDisplayName: String? Function(String hexPubkey)?`
  callback. For each npub mention/URI the renderer decodes the bech32
  to a hex pubkey via hexFromNpub() and asks the caller for a display
  name. If resolved → '@DisplayName'. If unresolved or callback absent
  → '@npub1abcdefgh…wxyz' truncated fallback. Style is the surrounding
  text's color with TextDecoration.underline — mentions inherit the
  bubble text color rather than the link color.
- Plumbed through WnMessageBubble and ChatMessageBubble as a single
  optional param at each layer; ChatScreen wires it to its existing
  `presentName(getAuthorMetadata(hex))` cache. Mentions of message
  authors resolve immediately; mentions of users not yet in the
  metadata cache fall back to truncation until metadata loads (a
  later follow-up can subscribe to mentioned pubkeys).
- 3 new widget tests: resolved name, empty-name fallback, no-callback
  fallback. 55 markdown tests pass. Coverage 99.03%.

* Plumb mentionDisplayName into the long-press action menu

When a user long-presses a message, the action menu re-renders the
bubble inside MessageActionsModal — a separate widget tree from the
chat list. The mentionDisplayName callback was not being forwarded to
that re-render, so @mentions fell back to truncated npubs in the
preview while showing resolved names in the main chat. Wire it through
MessageActionsScreen \u2192 MessageActionsModal \u2192 ChatMessageBubble.

* Bump whitenoise-rs to 7577289f (bare-npub parsing)

Picks up two commits on the markdown PR since 53fbbdd:

  c4ef3d99  address review feedback
              \u2014 cosmetic-only: drops `#[cfg(feature = "serde")]`
              gating (serde derives now unconditional), removes doc
              references to deleted PLAN.md, internal scanner refactor.
              Database path switches from re-deriving the AST on read
              to reading from a persisted `content_tokens` column \u2014
              same observable behavior at the FFI boundary.

  7577289f  bare npubs should also parse
              \u2014 the parser now recognizes bare `npub1\u2026` strings as
              `Inline::NostrMention` (previously only `@npub1\u2026` and
              `nostr:npub1\u2026` worked). Restricted to the `npub` HRP
              to avoid false positives on prose starting with
              `note1\u2026` / `nevent1\u2026`. No AST shape change.

No bridge or renderer changes needed: our existing NostrMention/Npub
path with the mentionDisplayName callback handles the new shape
identically.

* Bump whitenoise-rs to f421a8ad (fix nested-blockquote misparse)

Single fast-forward commit on the markdown PR:

  f421a8ad  fix bug with inner quotes inside quotes
              \u2014 trailing paragraphs after a blank \`> >\` line inside
              a nested blockquote were escaping to the document root.
              Verified by re-running the local AST dump on the user's
              repro input: 'Trailing paragraph in inner quote.' now
              sits as the third child of the inner BlockQuote, not as
              document.blocks[0].

No FFI shape changes; bridge regen is format-noise only (handled by
dart format).

* Tapping an @npub mention opens the user's profile in a shade

Per Vlad's design call. Previously, tapping a mention launched a
nostr:<bech32> URI externally; that bounced users out to the OS and
felt out of place inside a chat.

New flow:
- chat_message_bubble: when the renderer reports an Npub tap, decode
  bech32 to hex and open UserProfileShade in-app. Non-npub Nostr URIs
  (note/nevent/nprofile/naddr/nrelay) keep the external launchUrl
  behavior \u2014 those point to events, not people.
- lib/screens/user_profile_shade.dart: new HookConsumerWidget pushed
  via PageRouteBuilder with an opaque:false barrier. Wraps
  WnUserProfileCard inside a WnSlate. Header navigates back with
  Navigator.pop. For non-self users, a 'Start chat' button pops the
  shade and routes to StartChatScreen. Copy actions surface as
  SnackBars.
- The shade drops input focus before opening (mirrors what chat_screen
  does before MessageActionsScreen.show). Without this the chat list
  snaps to the bottom on dismiss because the keyboard reappears and
  resizes the viewport.
- 7 new shade tests cover: card rendered, button shown/hidden by
  isSelf, copy and copy-error snackbars, header dismiss, and the
  start-chat button popping then routing via a stub GoRouter.
- Updated chat_message_bubble tests: the npub tap test now asserts
  launchUrl is NOT called; a new test exercises the non-npub
  external-launch fallback.

4498 tests pass. Coverage 99.03%.

* Bump whitenoise-rs to 935683af (bare-URL autolinks + longer bech32)

Two fast-forward commits from f421a8ad:

  dbd595b3  nostr bech32 are not limited to 90ch
              \u2014 lifts the bech32 length cap from BIP-173's 90 to 1024
              per NIP-19, which explicitly waives 90 for TLV-encoded
              nevent / naddr / nprofile entities with multiple relay hints.
              Pure parser change.

  935683af  add better bare-url parsing for whitenoise, http, https, tel, mailto
              \u2014 bare URLs now parse as Inline::Autolink for the schemes
              http://, https://, mailto:, tel:, whitenoise://, and
              whitenoise-staging://. Trailing punctuation excluded.
              The opaque form 'whitenoise:foo' (no //) stays literal.

No AST shape change. Bare URLs flow through our existing autolink path.
Bridge regen is format-noise only.

Note: my URL allow-list in wn_markdown_text.dart includes http, https,
mailto, nostr, tel but not whitenoise(-staging). Bare whitenoise://
links will render styled but tap is a no-op until that's extended.

* Restyle @npub mentions: user color, bold, no @ prefix

Per the latest design call (Vlad). Mentions now render with three
properties:

- No '@' prefix.
- Bold (FontWeight.w700).
- Color seeded from the pubkey via AvatarColor.fromPubkey() \u2014 same
  palette the bubble already uses for sender names, so a mention of
  someone reads in the same hue as their own messages in a group.

Underline is dropped \u2014 color + bold is the new mention affordance.

When hexFromNpub fails to decode (malformed bech32), the mention falls
back to its surrounding text color so it stays bold but doesn't pretend
to identify someone.

Tests updated: drop '@' from expected strings, replace underline
assertions with FontWeight.w700 + per-pubkey color.

* Bump whitenoise-rs to bb717d1c (content_tokens backfill migration)

Two fast-forward commits from 935683af:

  6fae0178  fix stale comment
  bb717d1c  backfill migration to reparse content_tokens with markdown parser

The migration adds m0044_reparse_content_tokens.rs in the whitenoise
crate. It runs once at app init and reparses any persisted
content_tokens rows whose stored AST is older than the current parser
(necessary now that the AST is persisted instead of re-derived on each
read). All internal to whitenoise; no FFI surface change.

The only types.rs touch is a doc-comment update on
ChatMessage::content_tokens, removing the now-stale 'never persisted'
sentence.

* bump + just regenerate

* Address review: rename MarkdownText, honor list 'tight' flag, stable test keys

Three findings from PR review:

1. Drop the 'Wn' prefix on MarkdownText.
   Per CLAUDE.md, the 'Wn'/'wn_' prefix is for design-system widgets
   that are presentational only and make no Rust API calls. This
   widget calls hexFromNpub (Rust) for mention decoding, so it doesn't
   belong in the design-system namespace.
   - Renamed class WnMarkdownText -> MarkdownText.
   - Renamed file lib/widgets/wn_markdown_text.dart -> markdown_text.dart.
   - Renamed test file accordingly.
   - Updated imports in chat_message_bubble, wn_message_bubble, and
     both test files (66 references in total).

2. Honor MarkdownBlock_List.tight.
   The renderer previously destructured only kind and items and used a
   fixed 4.h gap between list items, ignoring the tight flag the
   parser emits. Now forwards tight to _buildList and picks a smaller
   inter-item gap (2.h) for tight lists vs a larger one (8.h) for
   loose lists, matching CommonMark semantics.

3. Stable keys on task-list checkbox icons.
   The two task-list rendering tests were keying off Icons.check_box
   and Icons.check_box_outline_blank via find.byIcon, which is fragile
   if the underlying Icon ever changes. Added Key('check_box') and
   Key('check_box_outline_blank') to the renderer's checkbox Icon and
   switched the tests to find.byKey.

All 4536 Flutter + 75 Rust tests pass. Coverage 99.05%.

* Tap-to-open whitenoise:// links; unify mention/profile UI under Start Chat shade

- Allow whitenoise:// and whitenoise-staging:// in markdown_text safe schemes
  so taps on the Rust-emitted autolinks dispatch to the bubble handler.
- whitenoise://chat/<id> routes through GoRouter; whitenoise://user/<npub>
  and bare @npub mentions now open the Start New Chat menu as a shade.
- Show a localized error dialog when a whitenoise:// URL fails to parse.
- Make StartChatScreen shade-capable (transparent scaffold, scrollable
  content) and remove the now-redundant UserProfileShade.

Co-authored-by: nvk <797193+nvk@users.noreply.github.com>

* Add @mention picker with rich input and npub display resolution

- Typing `@` in a group chat opens a member picker driven by useGroupMembers;
  selecting an entry inserts a styled `@DisplayName` token in the input.
- MentionTextEditingController tracks mention spans, renders them in the
  primary color via buildTextSpan, and exposes `messageText` so the wire
  representation is the bare `@npub1...` form regardless of how it's
  displayed.
- Bare `@npub1...` strings typed or pasted into the input are auto-tracked:
  if a name resolver is wired, they show as `@DisplayName`; otherwise they
  truncate to `@npub1abcdefgh…wxyz` matching the receiver bubble.
- chat_screen wires the resolver from getAuthorMetadata so mentions of users
  outside the current group still display by name when we know them, while
  the picker stays group-only.
- useChatInput now uses MentionTextEditingController and saves
  controller.messageText to drafts.

Adapted from PR #659 (rich message rendering / mentions); the receiver-side
markdown path on this branch already handles NostrMention inlines so no
bubble changes were needed.

Co-authored-by: erskingardner <202880+erskingardner@users.noreply.github.com>

* fix jumpy cursor bug

* coderabbit

* strikethrough

* render in reply content block thing

* fmt

* carrots for our rabbit, including THE GOLDEN CARROT

* tests: cover mention-controller branches and bubble truncation path

Restores coverage above the 99% gate after the markdown/mention/timestamp
fixes. New cases:

- insertMention shifts a later tracked mention via _TrackedMention.shift
- buildTextSpan emits a leading text segment before a mention span
- setMentionTargets snaps the cursor when it sat mid-URI
- setMentionTargets handles a known URI that sits past existing text
- _shiftMentions shifts a mention past a prefix insertion
- _replaceBareNpubs preserves and remaps a later mention via
  _shiftPastReplacements when a bare npub is inserted before it
- IME composing-at-mention-boundary preservation + the non-composing
  drop control case
- A tight finite-height truncation path for _TextWithTimestamp

* fix: synchronous recognizer disposal + permissive mention boundaries

Markdown link/nostr recognizers were being torn down via
addPostFrameCallback. If the widget was removed before the next frame
(Navigator.pop during scroll, hot reload, fast list rebuild), the
callback still ran but no longer had a clean owner. Switch to disposing
the previous frame's recognizers synchronously at the start of build,
before assigning the new list. Unmount cleanup via useEffect stays.

Add a testWidgets that mounts a MarkdownText with a link, tears the
subtree down, pumps a follow-up frame, and asserts no exception is
thrown. Use Flutter's experimentalLeakTesting + leak_tracker so any
leaked TapGestureRecognizer surfaces a test failure.

Also:
- _isMentionBoundary now accepts sentence punctuation (. , ; : ! ? ) ])
  so '@npub1...!' / '@npub1...,' auto-truncate the way the Rust parser
  already permits.
- markdown_text's display-name resolver trims the returned name before
  the empty-check, matching the controller's behavior and dropping
  whitespace-only resolutions back to the truncated fallback.

* fix: use theme color for markdown highlight fallback; move mention picker below images

Addresses two review notes:

1. r3261917942 ("Use colors set in theme") — markdown_text.dart:145.
   The default highlightColor was Colors.yellow, a raw Material color
   inconsistent with the rest of the codebase. Fall back to
   context.colors.intentionInfoContent, which matches how
   wn_message_bubble.dart already builds its highlight color when the
   caller doesn't specify one.

2. Mention picker UX — chat_screen.dart. When media attachments are
   present the @-picker was being rendered above the entire input box,
   pushing the image preview down. It now rides along inside the
   attachment area, sitting below the image previews but still above
   the text field. Without media it still appears above the input as
   before. Two new chat-screen tests pin both branches in place,
   asserting the picker's position relative to the media preview and
   the input via getTopLeft / getBottomLeft.

---------

Co-authored-by: nvk <797193+nvk@users.noreply.github.com>
Co-authored-by: erskingardner <202880+erskingardner@users.noreply.github.com>
2026-05-19 17:40:59 +02:00
2026-04-28 14:04:25 -04:00
2026-05-07 08:43:44 +02:00
2026-05-07 08:43:44 +02:00
2026-03-10 11:58:24 -03:00
2026-03-17 09:39:20 -03:00
2025-11-24 15:02:01 -03:00
2026-03-17 09:39:20 -03:00
2026-05-19 10:17:33 -04:00
2026-04-28 14:04:25 -04:00

White Noise

White Noise

A private, decentralized messenger built on Nostr using the Marmot protocol for MLS group encryption, with identity based on keypairs. Phone and email play no role in the system.

This is the Flutter app. The core messaging library and CLI live in whitenoise-rs.

What it does

Encrypted group messaging. White Noise uses MLS (Messaging Layer Security) for group chats, with forward secrecy and post-compromise security built in.

Keypair identity. Accounts are keypairs. Create one in the app or import your own. Phone numbers and email addresses play no part in this.

Decentralized transport. Messages route through Nostr relays. The architecture is serverless by design: any relay can carry your messages.

External signer support. Works with Amber and other NIP-55 signers. Your private key stays out of the app entirely.

Media. Send images and video with blurhash previews while loading. Attachments are encrypted at rest on device.

Search and conversation management. Find messages across all groups from a single search. Block or mute contacts, or archive entire chats.

Multi-account with encrypted local storage. Switch identities freely. App data is encrypted on device, and the local database migrates automatically on upgrade.

Open source. Released under the AGPL-3.0 license.

Supported Platforms

Platform Status
Android Supported
iOS Supported
macOS Planned
Windows Planned
Linux Planned
Web Planned

Stack

Layer Technology
UI Flutter (>=3.41.4)
Core Rust via whitenoise-rs
FFI flutter_rust_bridge
Protocol Marmot (MLS over Nostr)

Prerequisites

  • Flutter SDK (3.41.4 or later)
  • Rust (latest stable)
  • Just: cargo install just
  • flutter_rust_bridge_codegen: cargo install flutter_rust_bridge_codegen

Getting Started

just deps    # Install Flutter and Rust dependencies
just run     # Run on a connected device (staging flavor)

Structure

lib/
├── constants/   # Fixed, shared values
├── providers/   # Shared app state (Riverpod)
├── hooks/       # Ephemeral widget state
├── services/    # Stateless operations
├── screens/     # Full-page components
└── widgets/     # Reusable components

The rust/ directory holds a thin Rust crate that wraps whitenoise-rs and generates the Flutter bridge bindings.

Commands

just deps              # Install Flutter and Rust dependencies
just run               # Run on a connected device (staging)
just format            # Format Rust and Dart code
just lint              # Run Rust clippy and Flutter analyzer
just test-flutter      # Run Flutter tests
just test-rust         # Run Rust tests
just precommit         # Full pre-commit check (format, lint, test)
just coverage          # Check test coverage (minimum 99%)
just build-android     # Build Android APK
just build-ios         # Build Rust libs for iOS

Run just with no arguments to see all available commands.

Development Philosophy

  • Keep complexity low. Keep the app thin.
  • The whitenoise-rs crate is the source of truth. Avoid caching in Flutter.
  • Shared app state goes in providers. Ephemeral widget state goes in hooks.
  • Screens watch providers and pass data down to hooks.
  • Test coverage must stay at 99% or above.
  • Delete dead code. Commented code is dead code.
  • Write self-explanatory code. Comments are for the non-obvious.

Widgetbook

The repo includes a Widgetbook for developing and reviewing UI components in isolation.

just widgetbook-macos   # Run on macOS
just widgetbook-linux   # Run on Linux

Contributing

Read CONTRIBUTING.md before opening a PR. The short version: fork the repo, open or comment on an issue before building significant features, keep PRs under ~500 lines, always run just precommit, and include screenshots for UI changes.

Releases

See the releases page for changelogs and APK downloads.

Resources

License

White Noise is free and open source software, released under the AGPL-3.0 license.

S
Description
Mirror of White Noise
Readme
223 MiB