Skip to content

feat: add link previews to message composer - #3376

Merged
isekovanic merged 6 commits into
developfrom
feat/link-previews
Jan 30, 2026
Merged

isekovanic merged 6 commits into
developfrom
feat/link-previews

Conversation

@isekovanic

Copy link
Copy Markdown
Contributor

🎯 Goal

This PR adds the missing link previews feature that we've been missing on the React Native SDK.

It's essentially a way to prepopulate the message composer with a preview of the first link within the message we are composing, which is then going to be admitted as an attachment to the message we send.

As this is an optional feature, it first needs to be enabled by doing:

composer.updateConfig({
        linkPreviews: {
          enabled: true,
        }
      });

when configuring the composer state.

🛠 Implementation details

🎨 UI Changes

iOS
Before After
Android
Before After

🧪 Testing

☑️ Checklist

  • I have signed the Stream CLA (required)
  • PR targets the develop branch
  • Documentation is updated
  • New code is tested in main example apps, including all possible scenarios
    • SampleApp iOS and Android
    • Expo iOS and Android

@Stream-SDK-Bot

Stream-SDK-Bot commented Jan 30, 2026 •

Copy link
Copy Markdown
Contributor

SDK Size

title develop branch diff status
js_bundle_size 355 KB 355 KB 0 B 🟢

@isekovanic
isekovanic merged commit 72ff661 into develop Jan 30, 2026
5 checks passed
@isekovanic
isekovanic deleted the feat/link-previews branch January 30, 2026 16:13
isekovanic added a commit that referenced this pull request Nov 6, 2026
…#3920)

Ticket: https://linear.app/stream/issue/RN-485

## 🎯 Goal

First of a stack of PRs that bring agent conversations to
`ai-components`: reasoning, tool calls, approvals, sign-in and the
conversation view, as in StreamChatAI, ai-compose and
stream-chat-react#3376.

This one adds only the data model. No UI.

## 🛠 Implementation details

An agent writes each step of its reply as an attachment (`ai_reasoning`,
`ai_tool_call`). `parseMessageParts(message.attachments)` turns those
into typed parts and skips everything else, such as images and files.

Ported from React #3376 with the same names and behaviour:
- Lenient decoding: missing fields get defaults and wrong types are
ignored.
- Unknown kinds and newer versions (`v > 1`) keep their payload but have
no typed view.
- `approval` and `authorization` are parsed on tool calls.
- Helpers for the views to come: `isAwaitingApproval`,
`isAwaitingAuthorization`, `isAwaitingClient`, `isDeclined`,
`isToolCallFinished`, `isAuthorizationExpired`, `isSupportedPart`.

One deviation from React: the sign-in URL check doesn't use `URL`. On RN
< 0.80 its `protocol` and `host` getters throw "not implemented", which
would silently drop every sign-in on 0.76–0.79. It's a regex instead.

Exported from the `ai-components` entry only. The main entry is
unaffected.

## 🎨 UI Changes

None.

## 🧪 Testing

- React's `messageParts` suite ported to Jest.
- A fixture taken from a real reply written by a local Vision-Agents
router (reasoning → `search` tool call → reasoning).
- A regression test that fakes the pre-0.80 `URL` and checks a sign-in
still parses.
- `yarn lint`, `yarn test:typecheck`, `yarn build` and `yarn test:unit`
pass.

## ☑️ Checklist

- [x] I have signed the [Stream
CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform)
(required)
- [x] PR targets the `develop` branch
- [ ] Documentation is updated
- [ ] New code is tested in main example apps, including all possible
scenarios
  - [ ] SampleApp iOS and Android
  - [ ] Expo iOS and Android
isekovanic added a commit that referenced this pull request Nov 6, 2026
…als) (#3924)

Ticket: https://linear.app/stream/issue/RN-485

Stacked on #3921.

## 🎯 Goal

Show an agent reply's steps: its rounds of reasoning, its tool calls,
and the questions a tool asks before it runs. The views are ported from
React's AI components (#3376). Effects follow the native SDKs instead.

## 🛠 Implementation details

**Views** (all props-only, none of them reads `<Channel>`)
- `AIReasoning` shows a header that counts up while the agent thinks
("Thinking... 7s") and then reads "Thought for 12s" with the step's
summary.
- The panel opens while the agent thinks and follows the reasoning as it
streams in, then folds once the step is done. Tapping the header opens
or folds it.
- The first step takes over the turn's thinking clock, so the time
carries on from `AIThinking`.
- `AIThinking` is the same header for a turn that hasn't shown any
reasoning yet.
- `AIToolCall` shows the call's title, a detail line, a status icon and
its duration. The detail line covers waiting for approval, a sign-in or
the device, and declined, didn't complete or cancelled.
- `AIToolApproval` and `AIToolApprovalCard` ask the person a tool's
question, such as "Share your location?", with the agent's reason and
Allow / Don't Allow.
- The question is only asked of the targeted user on the targeted
install.
- The buttons wait while the answer is sent, and a failed send lets the
person answer again.
- `AIMessageParts` / `AIMessagePart` render a reply's steps in order,
with a neutral line for a kind this version can't show.
- `AIToolApproverProvider` / `useAIToolApprover` say who answers the
questions on this device.

**Shared bits**
- In-progress states use our `NativeShimmerView` through a small
internal `Shimmer`, which `AITypingIndicatorView` now uses too. Running
calls use the platform `ActivityIndicator`. This matches Swift and
Android, not React's CSS.
- Durations come out as "0.4s" and "1m 5s" on both platforms. Hermes on
iOS ignores narrow units in `Intl`, so we fall back the same way React
does.
- There's a new, optional `messageParts` section in the AI theme.
- New strings are added to all 13 locales. The thinking header reuses
the existing `Thinking...` key. The `ar` and `he` translations are new
and could use a native speaker's look.

**ExpoChatGPTSample**
- A reply renders its steps above its text.
- Live reasoning comes from calypso's `LiveReasoning`, fed by
`message.updated`, and "thinking" comes from the session's state.
- Approvals go through `session.decide`.
- The drawer lists the router's sessions.
- The steps take the row's full width. The stand-in `MessageList` sizes
bubbles to their content, and `AIConversation` replaces it later anyway.

## 🎨 UI Changes

Reasoning steps (folded, open, streaming), tool calls (running, waiting,
done, failed, declined) and the approval card.

## 🧪 Testing

- Unit tests are ported from React's `AIMessagePartViews` suite, plus
duration formatting, including the iOS fallback.
- On an Android emulator against a local Vision-Agents router:
- thinking, the reasoning steps folding and opening, and a web search
with its spinner, check and duration;
- the approval card: Share runs the call, Not now declines it, and no
answer shows the call as failed after the router's 30s;
- reasoning streaming into an open panel. Our router's models send their
thinking in one piece, so this was driven with router-shaped events.
- On the iOS simulator: a reply with a web search step and its duration.
- To get an approval at all, the sample needs a phone-side tool, and the
question has to carry the install id (`/respond` with `client_id`).
calypso 0.2.3's `responses.create` doesn't send one. Both come with the
client tool runner later in this stack.
- Not tested: RTL and screen readers, on either platform.
- `yarn lint`, `yarn typecheck`, `yarn build` and `yarn test:unit` pass.

## ☑️ Checklist

- [x] I have signed the [Stream
CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform)
(required)
- [ ] PR targets the `develop` branch (stacked on #3921)
- [ ] Documentation is updated
- [ ] New code is tested in main example apps, including all possible
scenarios
  - [ ] SampleApp iOS and Android
  - [x] Expo iOS and Android
isekovanic added a commit that referenced this pull request Nov 6, 2026
…3928)

Ticket: https://linear.app/stream/issue/RN-485

Stacked on #3926.

## 🎯 Goal

Show the person's messages and the agent's replies the way the native AI
SDKs do, with the actions under a finished reply. The APIs come from
React's AI components (#3376). The look follows StreamChatAI and the
Android AI SDK.

## 🛠 Implementation details

**Views** (props only, under `message/`)
- `AIUserMessage`: the person's attachments, then their text in a bubble
on the trailing side. With `onEdit` it shows an edit button.
- `AIAssistantMessage`: a reply's steps, then the answer (typed out
while it streams), then what the agent attached.
- `AIMessageAttachment`:
- An image is a 180 × 180 thumbnail that doesn't open on tap, as on both
native SDKs.
- A file is a card with its type's icon (PDF, image, audio, video,
archive or file), name, type and size. One at a web URL opens in the
browser.
- `AIReplyActions`: copy (shows a check for a moment), share through the
system sheet, regenerate with `onRegenerate`, and a thumbs up/down
rating with `rating` / `onRate`. Copy is hidden when the app has no
clipboard module.

**Customising**
- The AI theme has a new optional `message` section: styles, and colours
under StreamChatAI's names (`userMessageBackground`,
`attachmentIconBackground`, `messageAction`, …). The defaults come from
the design tokens.
- The theme also takes `icons`, which override any of the agent replies'
24 icons. This covers the step views from earlier PRs as well as the new
ones. `defaultAIIcons` and `useAIIcons` are exported. `mergeThemes`
carries icons through as they are, since the JSON copy it makes would
drop them.
- `AIUserMessage` and `AIAssistantMessage` take an `Attachment`
component, like the attachment view factories on Swift and Android.

**Also**
- Fixed: copying crashed with current `expo-clipboard`, which no longer
has `setString`. The AI clipboard service now uses `setStringAsync` when
it's there, as the core Expo package already does. This affected copying
code blocks too.
- New strings are in all 13 locales. As before, `ar` and `he` could use
a native speaker's look.

**Left for later**
- The native SDKs offer Copy and Edit on the person's message through a
long-press menu (Swift's context menu, Android's dropdown). Until then,
`onEdit` shows a button, as in React. If we want Swift's lifted preview,
the menu needs a portal host of its own, which `AIConversation` can own
in a later PR.

**ExpoChatGPTSample**
- Renders the person's messages with `AIUserMessage`, and replies with
`AIAssistantMessage` plus `AIReplyActions` once the router clears
`generating`.
- Ratings are kept for the session only, as in Juno: the router doesn't
take ratings yet. Like Juno, the sample doesn't wire edit or regenerate.

## 🎨 UI Changes

The person's bubble, replies with their steps and action row, and image
and file attachments.

## 🧪 Testing

- Unit tests cover:
  - the four views;
  - file size and details formatting;
  - the file type icons;
  - icon overrides, and icons surviving `mergeThemes`;
  - the `Attachment` slot.
- On an Android emulator and the iOS simulator, against a local
Vision-Agents router:
  - a web-search reply with its steps;
  - the actions appearing once the reply finished;
  - rating;
  - copy (pasting confirmed the text);
  - the share sheet;
  - image and PDF attachments;
  - the PDF opening in Chrome;
  - an image tap doing nothing.
- The attachments were posted straight to the channel. Asking with a
photo fails at the router ("personal conversations require a command
ID") because of how the sample sends photos with calypso 0.2.3, not
because of these views.
- Not tested: edit and regenerate on a device (the sample doesn't use
them), RTL and screen readers.
- `yarn lint`, `yarn typecheck`, `yarn build` and `yarn test:unit` pass.

## ☑️ Checklist

- [x] I have signed the [Stream
CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform)
(required)
- [ ] PR targets the `develop` branch (stacked on #3926)
- [ ] Documentation is updated
- [ ] New code is tested in main example apps, including all possible
scenarios
  - [ ] SampleApp iOS and Android
  - [x] Expo iOS and Android
isekovanic added a commit that referenced this pull request Nov 6, 2026
Ticket: https://linear.app/stream/issue/RN-485

Stacked on #3928.

## 🎯 Goal

A conversation view for AI agents, laid out by turn like StreamChatAI's
`AIConversationView` and React's `AIConversation` (#3376): each question
starts a turn, the agent's reply writes out below it, and a new question
goes to the top of the screen without moving anything else.

## 🛠 Implementation details

**`AIConversation`** (`conversation/`)
- Takes props only and never reads `<Channel>`, as in React: `messages`,
`pendingMessage`, `isThinking`, `loadOlderMessages`, `footer`,
`emptyContent`, `bottomInset`, `approver`, and the `UserMessage` /
`AssistantMessage` / `ReplyActions` slots plus `replyActions`.
- Split in three, like Swift's `AIConversationView` and its scroll view:
- `useConversationRows`: the data. Turns, a pending message until its
copy arrives, the thinking row, and a row cache so only changed messages
render again.
- `list/`: how the list behaves, as hooks that render nothing
(`usePinnedAnchor`, `useEndInView`, `useLoadOlder`, put together by
`useChatListController`), and an internal `AIChatList` that draws it.
Moving to another LegendList, such as keyboard-controller's, only
touches `AIChatList`.
  - `AIConversation` itself: the props, the slots and the row views.
- The list is Reanimated LegendList (`@legendapp/list` 3.7.1, JS only, a
regular dependency of the AI entry point; the isolation check keeps it
out of the main one), with recycling on.
- A reply's steps are keyed by message (new optional `id` on
`AIAssistantMessage`), since step ids like `r1` repeat across replies.
Reply actions are keyed too.
- The answer's typing starts afresh for a replaced text, so a reused row
shows its own text at once.
- The latest question is pinned with `anchoredEndSpace`, plus a content
floor (the question's position plus the list's tallest height at this
width). Without the floor, the list shrinking under the question (a
reasoning step folding, the thinking going, the keyboard closing) pulled
the question down before LegendList caught up. Thinking and the footer
are a row of their own for the same reason.
- Older messages load once the spinner has been in view for 300 ms, and
only when the list is at rest. On iOS, a page that lands while a finger
holds the list, or while it glides or bounces, pushes the view.
- Scroll-to-latest button (Swift's). It shows when the end of the latest
reply's content is out of view, not counting the space the pin leaves
under a short reply.

**Design tokens**
- Text in the AI components is sized from the chat SDK's tokens
(Md/Sm/Xs, 17/15/13 on iOS and 16/14/12 on Android), mapped to Swift's
font roles. Answers were RN's default 14 before. Headings scale from the
body as Swift's markdown does.
- The conversation's spacing comes from the tokens too.
- The isolation check now allows `theme/generated/light/StreamTokens*`,
which is plain data.

**Also**
- `fromStreamMessage` (React's) and `createFromStreamMessage`, which
returns the same `AIMessage` for the same Stream message object.
- `ArrowDown` icon, a `conversation` theme section (Swift's colours),
and two strings in all 13 locales. `ar` and `he` could use a native
speaker's look.
- Jest maps `@legendapp/list/{react-native,reanimated}` to their native
builds, since the exports map only has a `react-native` condition.

**ExpoChatGPTSample**
- Drops `<Channel>` and `<MessageList>` for `AIConversation`, as Juno
does. An `AgentChat` store watches the channel, follows message events,
pages older messages with `channel.query({ id_lt })` and keeps the live
reasoning.
- The question shows at once as the pending message, and "thinking"
comes from the session's state. Voice mode reads the session's state
instead of the AI indicator.

## 🎨 UI Changes

The conversation: turns, a new question pinned to the top with the reply
growing below, the spinner while older messages load, and the
scroll-to-latest button. Text is larger throughout (answers 17 on iOS,
16 on Android).

## 🧪 Testing

- Unit tests port React's `AIConversation` tests, and cover the rows
hook on its own, only changed rows re-rendering, the pending row keeping
its mount, loading older messages (delay, retry after a failure, waiting
for rest), repeated step ids across replies, a reused answer not typing
again, and `createFromStreamMessage`'s cache.
- On an Android emulator and the iOS simulator against a local
Vision-Agents router, and briefly on an iPhone:
- first and later questions, including asked with the keyboard up and
the keyboard then closed;
  - reasoning streaming and folding, and tool calls (web search);
  - the Linear sign-in card ("Not now"), stopping a reply, rating;
  - long replies and the scroll-to-latest button;
  - switching conversations;
  - loading older messages;
- recycling in a 34-turn conversation (rows reused 56 times): each row
showing its own message, no step state carried across replies, no answer
typing again.
- Not tested: TalkBack/VoiceOver and RTL; Connect on the sign-in card
(unchanged since #3926); approval cards and attachments in reused rows.
- Left for a polish pass: Swift's animations, the long-press Copy/Edit
menu, and trying react-native-keyboard-controller.
- `yarn lint`, `yarn typecheck`, `yarn build` and the unit tests pass.

## ☑️ Checklist

- [x] I have signed the [Stream
CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform)
(required)
- [ ] PR targets the `develop` branch (stacked on #3928)
- [ ] Documentation is updated
- [ ] New code is tested in main example apps, including all possible
scenarios
  - [ ] SampleApp iOS and Android
  - [x] Expo iOS and Android
isekovanic added a commit that referenced this pull request Nov 6, 2026
… fixes (#3931)

Ticket: https://linear.app/stream/issue/RN-485

Stacked on #3929.

## 🎯 Goal

The composer extras of StreamChatAI's `AIComposerView` and React's
`AIMessageComposer` (#3376): chat options such as "Think harder", the
chip of the one picked, and starter suggestions for an empty
conversation.

## 🛠 Implementation details

**Chat options** (`message-composer/ChatOptions.tsx`)
- `AIChatOption` is React's type: `id`, `title`, and optional
`description`, `icon`, `shortTitle` and `customData`.
- `ComposerView` takes `chatOptions` and lists them in its sheet under
Camera and Photos, as Swift's picker does. Picking one closes the sheet;
picking the picked one again removes it.
- The picked option shows as a chip above the input
(`SelectedChatOptionChip`, Swift's look), and is sent with each message
as `chatOption` in `onSendMessage`. Like Swift and Juno, it stays picked
after sending, until the person removes it.
- It lives in the composer's `state` store as `chatOption`, so an app
that passes its own store can read it. `useComposerChatOption` reads it
inside the composer.
- `ChatOptionsList` and `SelectedChatOptionChip` are exported for custom
composers.

**`AISuggestions`** (`suggestions/`)
- React's API: `suggestions` and `onSelect`. A row of chips scrolling
sideways, two lines each at most 160 wide, as Swift's `SuggestionsView`.
Where it goes is up to the app.

**Attachment preview**
- The remove button has a label and a bigger hit area.
- An attachment that isn't an image shows as a file card rather than an
empty `Image`, as React's preview does. The built-in pickers only pick
photos, so this shows only with a custom `mediaPickerService`.

**Also**
- `chatOptions` and `suggestions` theme sections, with Swift's colours
from the shared tokens. Sizes and spacing come from the tokens.
- Two strings in all 13 locales. `ar` and `he` could use a native
speaker's look.

**ExpoChatGPTSample**
- The placeholder sheet options (which only showed an alert) are gone.
In their place is "Think harder", which asks the router with
`reasoningEffort: 'high'`, as Juno does.
- Starters show above the composer on a new conversation, until its
first question. A starter asks with the option picked in the composer,
which Juno doesn't do.

## 🎨 UI Changes

The sheet with "Think harder" under Camera and Photos, the "Think" chip
above the input, and the starters above the composer on a new
conversation.

## 🧪 Testing

- Unit tests: the option is sent and stays picked, removing it from the
chip and by picking it again, a non-image attachment shown as a file and
removed, and the suggestions.
- On the iOS simulator and an Android emulator against a local
Vision-Agents router: picking the option, the chip, sending typed
questions and starters with it picked and removed, and removing it with
the keyboard up. A temporary log confirmed the sample sent `thinkHarder:
true` with the chip and `false` without.
- Not tested: TalkBack/VoiceOver and RTL, and the file card on device
(no picker gives one).
- `yarn lint`, `yarn typecheck`, `yarn build` and the unit tests pass.

## ☑️ Checklist

- [x] I have signed the [Stream
CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform)
(required)
- [ ] PR targets the `develop` branch (stacked on #3929)
- [ ] Documentation is updated
- [ ] New code is tested in main example apps, including all possible
scenarios
  - [ ] SampleApp iOS and Android
  - [x] Expo iOS and Android
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants