Commit Graph

6 Commits

Author SHA1 Message Date
Tom Brow
54ca2a3cc6 fix(mobile): render Markdown backslash escapes (#8925)
Mobile showed Markdown backslash escapes literally. A message linking a
channel as `[\#buzz-mobile](buzz://channel/…)` rendered as
`\#buzz-mobile`, and `\[text\]` rendered as LaTeX math. gpt_markdown has
no backslash escapes.

Outside code, each escaped ASCII punctuation character is now held as a
stand-in (an invisible word joiner plus the character's fullwidth form)
before parsing, so no other syntax (links, emphasis, mentions, channel
tokens, math) matches it. A new inline component and the link and image
builders restore the original character. The inbox/search preview
builder already handled escapes this way; its helpers moved to a shared
`markdown_escapes.dart`.

Two edge cases found in review are covered: shielding runs after
trailing-gallery extraction so carousel URLs and labels stay as
authored, and a bare link no longer takes a trailing backslash into its
destination. Codex review added: escaped backticks stay literal instead
of opening code, authored word joiners are not mistaken for stand-ins,
Buzz URL punctuation peeling keeps a trailing backslash, gallery
semantic labels drop escapes, the trailing-backslash peel is linear and
reruns after delimiter stripping, an escaped `\<url>` is not treated as
an autolink, `~~~` fences are skipped like backtick fences, an autolink
containing `\` keeps it in both label and destination, and the stand-ins
use fullwidth characters so the one gpt_markdown fallback that shows raw
text (multi-line strikethrough) still reads right.

Later review: fence detection looks back only over blanks and `>`
markers, so it stays linear on long lines (security review) and treats
an unclosed fence inside a quote or list as code, like the renderer
(Carl).

Behavior change: `\(x\)` and `\[x\]` now show as `(x)` and `[x]` instead
of math, per CommonMark. Desktop (buzz-app) doesn't render math either.
`$…$` math is unchanged.

### Validation

- Added tests: a `MessageContent` table covering escapes in prose, bold,
link labels (HTTP and `buzz://channel`), fenced and inline code (left
literal), a bare link ending in `\`, and escaped backticks, authored
word joiners and fullwidth characters, a `~~~` fence, a multi-line
strikethrough, `<https://example.com/a\>`, a Buzz URL ending in `.\`, an
emphasized bare URL ending in `\*`, an escaped `\<url>`, and a trailing
image gallery with escaped labels (including its semantics label), in
plain and bubble layouts. Each row also asserts no stand-in reaches
visible text or semantics. Reverting any one of these fixes fails it.
- `flutter analyze` is clean and the full mobile suite passes, except
for 11 `notification_toggle_test.dart` tests. Those fail locally on the
Studio at the CI-green a3964ab as well, so they are environmental and
unrelated to this change. A 256 KiB regression test covers the linear
fence scan, and it fails on the earlier code.
- iPhone 17 Pro simulator (iOS 27) signed in to the internal community:
loganj's original message now shows `#buzz-github-prs` and
`#buzz-mobile`.
- Full-app run on
[a3964ab](a3964ab08c):
debug build on a fresh iPhone 17 Pro simulator (iOS 27), signed in to
the internal community with live data. Covered the #buzz-github-prs
timeline (long linked lists, bare URLs, mentions, reactions, thread
summaries), a thread view, Inbox previews (the shared preview helpers),
a DM with a bare link and a channel link, and search results. Everything
rendered as before apart from the fixed escapes, with no stray stand-in
characters. Limitations: debug build, iOS only, no gallery or code-block
messages were in the visited history (the widget tests cover those), and
a 165-second recording was taken but GitHub can't take it through our
upload script. Two quirks seen are pre-existing and unchanged here: a
bare link absorbs a trailing comma, and search shows raw Markdown for
messages over 512 characters.

<details><summary>Full-app screenshots</summary>

| Channel | Channel (scrolled) | Thread |
| --- | --- | --- |
|
![channel](https://raw.githubusercontent.com/block/buzz/1b0b3e0052f65fbc7b0aca678fc1b45d1265d8c3/pr-8925--fullapp_02_channel.png)
|
![scrolled](https://raw.githubusercontent.com/block/buzz/1b0b3e0052f65fbc7b0aca678fc1b45d1265d8c3/pr-8925--fullapp_04_scroll.png)
|
![thread](https://raw.githubusercontent.com/block/buzz/1b0b3e0052f65fbc7b0aca678fc1b45d1265d8c3/pr-8925--fullapp_05_thread.png)
|
| **Inbox** | **DM** | **Search** |
|
![inbox](https://raw.githubusercontent.com/block/buzz/1b0b3e0052f65fbc7b0aca678fc1b45d1265d8c3/pr-8925--fullapp_06_inbox.png)
|
![dm](https://raw.githubusercontent.com/block/buzz/1b0b3e0052f65fbc7b0aca678fc1b45d1265d8c3/pr-8925--fullapp_07_dm.png)
|
![search](https://raw.githubusercontent.com/block/buzz/1b0b3e0052f65fbc7b0aca678fc1b45d1265d8c3/pr-8925--fullapp_08_search.png)
|

</details>

### Before / after

Before (production build, reported):


![01-before](https://raw.githubusercontent.com/block/buzz/809b2fccf98b41dc612acb9a32c9dd880ff40403/pr-8925--01-before.png)

After (this branch, same message):


![02-after](https://raw.githubusercontent.com/block/buzz/809b2fccf98b41dc612acb9a32c9dd880ff40403/pr-8925--02-after.png)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 10:35:54 -08:00
Tom Brow
20742a236d fix(mobile): only render known channels as #channel chips (#8862)
Follows [#8860](https://github.com/block/buzz/pull/8860) (merged).

On mobile, any `#token` rendered as a channel chip, so GitHub-style
references like `#8700` turned into inert chips for channels that don't
exist. Desktop already avoids this by only linking names in the known
channel list.

This makes mobile's message body and one-line preview do the same. With
no known names (channels still loading, or none visible), nothing
becomes a chip. An open channel the reader hasn't joined stays plain
text until the directory has loaded; before this change it was an inert,
untappable chip. messages rebuild with chips once channel names arrive.
Unlike desktop, there's no "any `#token` while empty" fallback, because
an empty map can't distinguish loading from an account with no visible
channels. The known names come from the existing
`channelIdsByNameProvider` (the reader's channels, plus open directory
channels once Browse channels or invite recovery has loaded the
directory), so there is no new fetch, subscription or sync change. When
the list is non-empty, the regex has one alternative fewer than before.

### Validation

Added tests:
- Preview: `#8700`, `#8774's`, a known-name prefix (`#gen` vs `general`)
and `#eng-ios` with only `eng` known stay text, while `#general` and a
longer known `#eng-ios` are chips. With no channel names, nothing chips.
- Message body: `Took over #8700 from #general` renders one chip
(`general`) and keeps `#8700` as text.

- Message body: with no channel names, `#unknown` stays text.

The new assertions fail with the previous always-generic pattern. The
full mobile test suite passed in pre-push.

Simulator render on an iPhone 18 Pro (iOS 27): real message-body and
preview widgets with channels `general` and `eng` loaded, rendered with
both stacked PRs applied.

| Before (`main`) | After
([#8860](https://github.com/block/buzz/pull/8860) +
[#8862](https://github.com/block/buzz/pull/8862)) |
| --- | --- |
| <img
src="https://raw.githubusercontent.com/block/buzz/5afead6a162d5c8554ad17175fc2dccff93bf2af/pr-8862--01-before-main.png"
width="300"> | <img
src="https://raw.githubusercontent.com/block/buzz/5afead6a162d5c8554ad17175fc2dccff93bf2af/pr-8862--02-after-both-prs.png"
width="300"> |

Human app test: not applicable under the mobile-client testing
exception; agent review and simulator validation above.

buzz-review-completed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
2026-10-31 09:47:19 -07:00
Tom Brow
b80217de32 fix(mobile): end #channel tokens at apostrophes, quotes and dashes (#8860)
Mobile renders `#channel` tokens as chips, but a token followed by an
apostrophe, quote, ellipsis or dash stayed plain text: `#general's`,
`"#general"`, `#general…` and `#general—` all lost their chip because
the token only ended at whitespace or `, ; . ! ? : ) ] }`.

This ends channel tokens at those characters too (`' " ‘ ’ “ ” » … –
—`). The message body and the one-line preview each had their own copy
of the channel regex; they now share one `channelTokenPattern`, so they
can't drift apart again.

`@mention` boundaries are unchanged on purpose: on mobile and desktop
the same boundary also decides which mentions are bound and tagged when
sending, so changing it is a separate, cross-surface decision.

### Validation

Added tests:
- Preview table covering each new trailing character (and matching
opening quotes).
- Message body: `#general's` keeps the chip and renders the `'s` suffix.

Both fail with the previous boundary. The full mobile test suite passed
in pre-push.

Simulator render on an iPhone 18 Pro (iOS 27): real message-body and
preview widgets with channels `general` and `eng` loaded, rendered with
both stacked PRs applied.

| Before (`main`) | After
([#8860](https://github.com/block/buzz/pull/8860) +
[#8862](https://github.com/block/buzz/pull/8862)) |
| --- | --- |
| <img
src="https://raw.githubusercontent.com/block/buzz/5afead6a162d5c8554ad17175fc2dccff93bf2af/pr-8862--01-before-main.png"
width="300"> | <img
src="https://raw.githubusercontent.com/block/buzz/5afead6a162d5c8554ad17175fc2dccff93bf2af/pr-8862--02-after-both-prs.png"
width="300"> |

Human app test: not applicable under the mobile-client testing
exception; agent review and simulator validation above.

buzz-review-completed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
2026-10-31 09:30:23 -07:00
Tom Brow
be1ac392e8 perf(mobile): render inbox and search previews as plain text (#8675)
### Summary

Inbox rows and search results show each message as a 2-line preview.
That preview was a capped `MessageContent`, so gpt_markdown parsed and
laid out the whole source (syntax-highlighted code blocks and measured
tables included), even though only two lines are painted. A native trace
on an iPhone 12 mini attributed a 102 ms inbox-open frame mostly to this
work.

Previews now skip Markdown entirely. `messagePreviewSource` takes only
the leading lines a preview can show, and at most the first 4,000
characters of a single long line, so link normalization, mention binding
and emoji lookup never scan the rest of a long message.
`messagePreviewPieces` is then a linear pass over those lines, similar
to desktop's `getMarkdownPreviewText`. It drops block syntax (headings,
quotes, list markers, rules, table dividers, code fences) and unwraps
inline emphasis and `<u>`, honoring backslash escapes and keeping inline
(including multi-backtick) and fenced code as literal text. It also
replaces links with their label. The new `MessagePreview` widget renders
the result as a single `Text.rich` with `maxLines` and an ellipsis.

Tokens keep the look they have in the thread view:
- A bound mention is the same `MessageMentionPill` chip, with the bot
icon for agents. An ambiguous mention stays as text.
- `#channel` tokens and `buzz://channel/...` links are the same channel
token chip.
- A `buzz://message`, repo, PR or issue link that shows its own URL
previews with the thread view's short label, such as `general ·
abcdef01`. Both use the shared `buzzLinkLabel` helper.
- Custom emoji render as inline images. Only shortcodes the message
names are looked up in the palette.
- Media links show as "Image", "Video" or "Audio".

Chips in previews are not tappable, and tapping the row opens the
message. Bold, italic, inline code and link styling are no longer shown
in previews. The timeline and thread view are unchanged, and so is the
profile status line, which still uses `MessageContent`.

This replaces this PR's earlier approach of cutting the Markdown source
before parsing. That approach needed delimiter-balance heuristics, and a
security review found a real case (`**` inside a link label) where the
heuristic misfired.

### Validation

- `preview_text_test.dart` is a table test of the parser: block syntax,
emphasis that must and must not unwrap (`snake_case`, a lone `*`),
inline and fenced code, unpaired backticks, links, tables, the line
limit and the long-line cap. It also covers mentions (bound, ambiguous,
inside emphasis, link labels and code), custom emoji (including adjacent
`🅰️b:`), channel tokens and links, message and repo link labels, and
media. The long-line row also asserts that `messagePreviewSource`
returns a bounded prefix.
- `message_preview_test.dart` checks the widget: no `GptMarkdown`, one
capped and ellipsized paragraph, mention chip, custom emoji image,
channel chip, media label and empty content.
- Existing inbox and search tests updated to find `MessagePreview`,
including the agent-chip test in search.
- `flutter analyze` and the full mobile test suite pass at 8c721ba3a
(with `--dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example`, as
`just mobile-test` runs it).
- Device A/B on an iPhone 12 mini, using Profile builds against the real
inbox on the internal relay. Each run was a cold launch followed by 6
Inbox opens, in the order main, fix, main, fix:

  | | main eda26b942 | this PR 6661d594a |
  |---|---|---|
| First open, worst frame (run 1 / run 2) | 40.4 / 34.9 ms | 22.4 / 23.2
ms |
| First open, build total (run 1 / run 2) | 77.8 / 91.1 ms | 38.5 / 52.3
ms |
  | All opens, median worst frame | 27.2 ms | 23.3 ms |
  | All opens, median build total | 43.4 ms | 26.6 ms |

First open drops about 40% in build time and about 14 ms in worst frame.
On warm opens, build time also drops about 40%, but the worst frame
barely moves, so what remains in those frames is not the preview parse.

## Codex review status

Codex reviewed this PR over four rounds. Every finding about cost or
correctness is fixed, and those threads are resolved. Later rounds
turned up narrower and narrower preview edge cases, so the author
decided to stop iterating on Codex findings. Any Codex thread still
open, or any finding Codex posts after f538853ed, is a deliberately
accepted preview limitation and doesn't block merging. Example: a [code
span that continues across a line
break](https://github.com/block/buzz/pull/8675#discussion_r4415196032)
can preview a mention as a chip. The full message view is unaffected.
Only the one- or two-line inbox and search previews can differ from it.

buzz-review-completed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:54:32 -07:00
klopez4212
1ac0a1271b fix(mobile): preserve Markdown attachment destinations (#8168)
## Summary
Fix mobile “Image unavailable” errors caused by angle-bracketed Markdown
URLs. Preserve attachment destinations across images, galleries, videos,
and file links, including spaces, parentheses, and optional titles.

### Related issue
Related: #8086 (attachment filenames).

### Testing
- `just mobile-install mobile-check mobile-test`
- Regression coverage decodes PNG/JPEG/GIF/WebP/BMP and verifies gallery
viewers, video routing, and authenticated file links.
- Native device playback not tested.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-10-08 14:05:46 -04:00
Taylor Ho
5ddf23d700 feat(mobile-messages): render compact Buzz permalink chips (#5639)
**Category:** improvement
**User Impact:** Buzz channel, message, repository, pull request, and
issue links now display recognizable context and navigate reliably in
the mobile app.
**Problem:** Bare Buzz permalinks appeared as raw or ambiguous URLs on
mobile, while channel and message links were not handled consistently
across Markdown forms and startup states.
**Solution:** Normalize eligible bare Buzz URLs without consuming
Markdown syntax, render them as semantic icon-prefixed chips, and route
channel/message targets through the mobile deep-link dispatcher while
preserving authored Markdown labels as ordinary links.

<details>
<summary>File changes</summary>

**mobile/lib/features/channels/deep_link_dispatcher.dart**
Routes parsed channel and message links through the appropriate in-app
navigation callbacks.

**mobile/lib/features/channels/message_content.dart**
Presents all bare Buzz permalinks as semantic icon chips and keeps
authored labels as ordinary links.

**mobile/lib/features/channels/message_content/link_normalizer.dart**
Normalizes bare and autolinked Buzz URLs without consuming Markdown
delimiters, code, or punctuation.

**mobile/lib/shared/deeplink/deep_link.dart**
Adds strict channel and project-entity parsing alongside message deep
links.

**mobile/lib/shared/deeplink/pending_deep_link_provider.dart**
Preserves pending navigation until the mobile routing surface is ready.

**mobile/test/features/channels/channel_detail_page_test.dart**
Updates navigation integration coverage for icon-prefixed channel chips.

**mobile/test/features/channels/deep_link_dispatcher_test.dart**
Covers channel/message dispatch and missing-target behavior.


**mobile/test/features/channels/message_content/link_normalizer_test.dart**
Exercises Markdown-safe normalization across the full Buzz link suite.

**mobile/test/features/channels/message_content_test.dart**
Verifies chip labels, icons, semantics, authored-label opt-out, and
navigation callbacks.

**mobile/test/shared/deeplink/deep_link_test.dart**
Covers strict parsing for channel, message, repository, pull-request,
and issue links.

</details>

## Reproduction steps
1. Run the mobile app and open a channel containing bare
`buzz://channel`, `buzz://message`, `buzz://repo`, `buzz://pr`, and
`buzz://issue` URLs.
2. Confirm each bare URL renders as one cohesive chip with a type icon,
a useful name or shortened identifier, and no duplicated channel `#`
character.
3. Add an authored Markdown link such as `[design
discussion](buzz://issue?...)` and confirm the supplied label remains an
ordinary link rather than becoming a chip.
4. Select channel and message links and confirm they navigate correctly
from inline and autolinked forms.

## Screenshots / demos
**iOS Simulator — channel, message, repository, pull request, and issue
permalink chips**

Real app build (`37b2cb5eb`) running on an iPhone 17 Pro simulator.

![Mobile permalink chips on iOS
Simulator](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/5639/mobile-permalink-chips-simulator.png)

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
2026-08-14 10:44:58 -07:00