From 377305d291116ffa963e64785a8d2b251c27fbda Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 30 Jan 2026 16:31:29 +0100 Subject: [PATCH 1/5] feat: add link previews to message composer --- examples/SampleApp/App.tsx | 3 + examples/SampleApp/yarn.lock | 8 +- package/package.json | 2 +- .../components/MessageInput/MessageInput.tsx | 27 ++- .../components/LinkPreviewList.tsx | 162 ++++++++++++++++++ .../MessageInput/hooks/useLinkPreviews.ts | 27 +++ .../src/contexts/themeContext/utils/theme.ts | 24 +++ package/yarn.lock | 8 +- 8 files changed, 245 insertions(+), 16 deletions(-) create mode 100644 package/src/components/MessageInput/components/LinkPreviewList.tsx create mode 100644 package/src/components/MessageInput/hooks/useLinkPreviews.ts diff --git a/examples/SampleApp/App.tsx b/examples/SampleApp/App.tsx index 4bdc24765..60f518b38 100644 --- a/examples/SampleApp/App.tsx +++ b/examples/SampleApp/App.tsx @@ -196,6 +196,9 @@ const App = () => { drafts: { enabled: true, }, + linkPreviews: { + enabled: true, + } }); setupCommandUIMiddlewares(composer); diff --git a/examples/SampleApp/yarn.lock b/examples/SampleApp/yarn.lock index 784af4a07..e365c6aa1 100644 --- a/examples/SampleApp/yarn.lock +++ b/examples/SampleApp/yarn.lock @@ -8349,10 +8349,10 @@ stream-chat-react-native-core@8.1.0: version "0.0.0" uid "" -stream-chat@^9.27.2: - version "9.27.2" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.27.2.tgz#5b41173e513f3606c47c93f391693b589e663968" - integrity sha512-OdALDzg8lO8CAdl8deydJ1+O4wJ7mM9dPLeCwDppq/OQ4aFIS9X38P+IdXPcOCsgSS97UoVUuxD2/excC5PEeg== +stream-chat@^9.30.1: + version "9.30.1" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.30.1.tgz#86d152e4d0894854370512d17530854541f7990b" + integrity sha512-8f58tCo3QfgzaNhWHpRQzEfglSPPn4lGRn74FFTr/pn53dMJwtcKDSohV6NTHBrkYWTXYObRnHgh2IhGFUKckw== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14" diff --git a/package/package.json b/package/package.json index 57645fa6d..75a84d386 100644 --- a/package/package.json +++ b/package/package.json @@ -80,7 +80,7 @@ "path": "0.12.7", "react-native-markdown-package": "1.8.2", "react-native-url-polyfill": "^2.0.0", - "stream-chat": "^9.27.2", + "stream-chat": "^9.30.1", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { diff --git a/package/src/components/MessageInput/MessageInput.tsx b/package/src/components/MessageInput/MessageInput.tsx index b091d2b87..8f5ab6eba 100644 --- a/package/src/components/MessageInput/MessageInput.tsx +++ b/package/src/components/MessageInput/MessageInput.tsx @@ -25,10 +25,13 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { type MessageComposerState, type TextComposerState, type UserResponse } from 'stream-chat'; +import { LinkPreviewList } from './components/LinkPreviewList'; import { OutputButtons } from './components/OutputButtons'; import { useAudioRecorder } from './hooks/useAudioRecorder'; import { useCountdown } from './hooks/useCountdown'; +import { useHasLinkPreviews } from './hooks/useLinkPreviews'; + import { ChatContextValue, useAttachmentManagerState, @@ -262,6 +265,9 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { const { quotedMessage } = useStateStore(messageComposer.state, messageComposerStateStoreSelector); const { height } = useStateStore(messageInputHeightStore.store, messageInputHeightStoreSelector); + + const hasLinkPreviews = useHasLinkPreviews(); + const { theme: { semantics, @@ -530,7 +536,8 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { /> ) : ( <> - { ]} > {InputButtons && } - + { ]} > - { ) : null} - + + - + {command ? ( ) : ( @@ -599,7 +612,7 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { - + diff --git a/package/src/components/MessageInput/components/LinkPreviewList.tsx b/package/src/components/MessageInput/components/LinkPreviewList.tsx new file mode 100644 index 000000000..61c9f784c --- /dev/null +++ b/package/src/components/MessageInput/components/LinkPreviewList.tsx @@ -0,0 +1,162 @@ +import React, { useCallback, useMemo } from 'react'; + +import { View, Text, StyleSheet } from 'react-native'; + +import Animated, { LinearTransition } from 'react-native-reanimated'; + +import type { LinkPreview } from 'stream-chat'; +import { LinkPreviewsManager } from 'stream-chat'; + +import { AttachmentRemoveControl } from './AttachmentPreview/AttachmentRemoveControl'; + +import { useChatContext, useMessageComposer, useTheme } from '../../../contexts'; +import { NewLink } from '../../../icons/NewLink'; +import { components, primitives } from '../../../theme'; +import { useLinkPreviews } from '../hooks/useLinkPreviews'; + +export type LinkPreviewListProps = { + displayLinkCount?: number; +}; + +export const LinkPreviewList = ({ displayLinkCount = 1 }: LinkPreviewListProps) => { + const linkPreviews = useLinkPreviews(); + + if (linkPreviews.length === 0) return null; + + return ( + <> + {linkPreviews.slice(0, displayLinkCount).map((linkPreview) => ( + + ))} + + ); +}; + +type LinkPreviewProps = { + linkPreview: LinkPreview; +}; + +export const LinkPreviewCard = ({ linkPreview }: LinkPreviewProps) => { + const styles = useStyles(); + const { ImageComponent } = useChatContext(); + const { linkPreviewsManager } = useMessageComposer(); + const { image_url, thumb_url, title, text, og_scrape_url } = linkPreview; + + const dismissPreview = useCallback( + () => linkPreviewsManager.dismissPreview(og_scrape_url), + [linkPreviewsManager, og_scrape_url], + ); + + if ( + !LinkPreviewsManager.previewIsLoaded(linkPreview) && + !LinkPreviewsManager.previewIsLoading(linkPreview) + ) { + return null; + } + + return ( + + + + + + + {title ? ( + + {title} + + ) : null} + {text ? ( + + {text} + + ) : null} + {og_scrape_url ? ( + + + + {og_scrape_url} + + + ) : null} + + + + + + + ); +}; + +const useStyles = () => { + const { + theme: { + semantics, + messageInput: { linkPreviewList }, + }, + } = useTheme(); + + return useMemo( + () => + StyleSheet.create({ + linkContainer: { + flexDirection: 'row', + ...linkPreviewList.linkContainer, + }, + linkIcon: { alignSelf: 'center', marginRight: 4, ...linkPreviewList.linkIcon }, + container: { + flexDirection: 'row', + backgroundColor: semantics.chatBgOutgoing, + padding: primitives.spacingXs, + borderRadius: components.messageBubbleRadiusAttachment, + ...linkPreviewList.container, + }, + imageWrapper: { + flexDirection: 'row', + overflow: 'hidden', + ...linkPreviewList.imageWrapper, + }, + dismissWrapper: { + position: 'absolute', + right: 0, + top: 0, + ...linkPreviewList.dismissWrapper, + }, + thumbnail: { + borderRadius: components.messageBubbleRadiusAttachment, + height: 40, + width: 40, + ...linkPreviewList.thumbnail, + }, + wrapper: { + paddingVertical: primitives.spacingXxs, + ...linkPreviewList.wrapper, + }, + metadataContainer: { + marginLeft: primitives.spacingXs, + flex: 1, + minWidth: 0, + ...linkPreviewList.metadataContainer, + }, + text: { + fontSize: primitives.typographyFontSizeXs, + // TODO: Change this to a better semantic once chatTextOutgoing is available + color: semantics.brand900, + ...linkPreviewList.text, + }, + titleText: { + fontWeight: primitives.typographyFontWeightBold, + fontSize: primitives.typographyFontSizeXs, + // TODO: Change this to a better semantic once chatTextOutgoing is available + color: semantics.brand900, + ...linkPreviewList.titleText, + }, + }), + [linkPreviewList, semantics.brand900, semantics.chatBgOutgoing], + ); +}; diff --git a/package/src/components/MessageInput/hooks/useLinkPreviews.ts b/package/src/components/MessageInput/hooks/useLinkPreviews.ts new file mode 100644 index 000000000..4f4e7beb8 --- /dev/null +++ b/package/src/components/MessageInput/hooks/useLinkPreviews.ts @@ -0,0 +1,27 @@ +import { LinkPreviewsManager, LinkPreviewsManagerState } from 'stream-chat'; + +import { useMessageComposer } from '../../../contexts'; +import { useStateStore } from '../../../hooks'; + +const linkPreviewsManagerStateSelector = (state: LinkPreviewsManagerState) => ({ + linkPreviews: Array.from(state.previews.values()).filter((preview) => + LinkPreviewsManager.previewIsLoaded(preview), + ), +}); + +export const useLinkPreviews = () => { + const messageComposer = useMessageComposer(); + const { linkPreviewsManager } = messageComposer; + const { linkPreviews } = useStateStore( + linkPreviewsManager.state, + linkPreviewsManagerStateSelector, + ); + + return linkPreviews; +}; + +export const useHasLinkPreviews = () => { + const linkPreviews = useLinkPreviews(); + + return linkPreviews.length > 0; +}; diff --git a/package/src/contexts/themeContext/utils/theme.ts b/package/src/contexts/themeContext/utils/theme.ts index 6bf086b5b..330bfe85b 100644 --- a/package/src/contexts/themeContext/utils/theme.ts +++ b/package/src/contexts/themeContext/utils/theme.ts @@ -422,6 +422,18 @@ export type Theme = { upload: ImageStyle; }; wrapper: ViewStyle; + linkPreviewList: { + linkContainer: ViewStyle; + linkIcon: ViewStyle; + container: ViewStyle; + imageWrapper: ViewStyle; + dismissWrapper: ViewStyle; + thumbnail: ImageStyle; + wrapper: ViewStyle; + metadataContainer: ViewStyle; + text: TextStyle; + titleText: TextStyle; + }; }; messageList: { container: ViewStyle; @@ -1227,6 +1239,18 @@ export const defaultTheme: Theme = { upload: {}, }, wrapper: {}, + linkPreviewList: { + linkContainer: {}, + linkIcon: {}, + container: {}, + imageWrapper: {}, + dismissWrapper: {}, + thumbnail: {}, + wrapper: {}, + metadataContainer: {}, + text: {}, + titleText: {}, + }, }, messageList: { container: {}, diff --git a/package/yarn.lock b/package/yarn.lock index 6eb085514..88861a9cc 100644 --- a/package/yarn.lock +++ b/package/yarn.lock @@ -8357,10 +8357,10 @@ stdin-discarder@^0.2.2: resolved "https://registry.yarnpkg.com/stdin-discarder/-/stdin-discarder-0.2.2.tgz#390037f44c4ae1a1ae535c5fe38dc3aba8d997be" integrity sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ== -stream-chat@^9.27.2: - version "9.27.2" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.27.2.tgz#5b41173e513f3606c47c93f391693b589e663968" - integrity sha512-OdALDzg8lO8CAdl8deydJ1+O4wJ7mM9dPLeCwDppq/OQ4aFIS9X38P+IdXPcOCsgSS97UoVUuxD2/excC5PEeg== +stream-chat@^9.30.1: + version "9.30.1" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.30.1.tgz#86d152e4d0894854370512d17530854541f7990b" + integrity sha512-8f58tCo3QfgzaNhWHpRQzEfglSPPn4lGRn74FFTr/pn53dMJwtcKDSohV6NTHBrkYWTXYObRnHgh2IhGFUKckw== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14" From 49d135bf0e54f248888aa0524c669328abf33bce Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 30 Jan 2026 16:38:23 +0100 Subject: [PATCH 2/5] fix: update snapshot --- .../Thread/__tests__/__snapshots__/Thread.test.js.snap | 3 +++ 1 file changed, 3 insertions(+) diff --git a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap index dd198003b..7131ac319 100644 --- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap +++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap @@ -1908,6 +1908,7 @@ exports[`Thread should match thread snapshot 1`] = ` } > Date: Fri, 30 Jan 2026 16:56:52 +0100 Subject: [PATCH 3/5] chore: resolve conflicts --- .../components/MessageInput/MessageInput.tsx | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/package/src/components/MessageInput/MessageInput.tsx b/package/src/components/MessageInput/MessageInput.tsx index 3639bc3e0..b3f13ff5c 100644 --- a/package/src/components/MessageInput/MessageInput.tsx +++ b/package/src/components/MessageInput/MessageInput.tsx @@ -422,7 +422,8 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { layout={LinearTransition.duration(200)} > {isRecordingStateIdle ? ( - { ]} > {InputButtons && } - + ) : null} - { ) : null} {isRecordingStateIdle ? ( - { ) : null} - + + ) : null} - + {!isRecordingStateIdle ? ( ) : ( @@ -505,9 +514,9 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { ) : null} - + - + )} From 4baeb452c1f3fb2a793e5e78827f5f1ef1430d33 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 30 Jan 2026 17:03:49 +0100 Subject: [PATCH 4/5] fix: remove redundant animated view --- package/src/components/MessageInput/MessageInput.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/package/src/components/MessageInput/MessageInput.tsx b/package/src/components/MessageInput/MessageInput.tsx index b3f13ff5c..80636d8ca 100644 --- a/package/src/components/MessageInput/MessageInput.tsx +++ b/package/src/components/MessageInput/MessageInput.tsx @@ -417,10 +417,7 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { {Input ? ( ) : ( - + {isRecordingStateIdle ? ( { - + )} From 6bd12f5e87f37199c2c1f1ff82d2b81690f30e10 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 30 Jan 2026 17:06:44 +0100 Subject: [PATCH 5/5] fix: update snapshot again --- .../Thread/__tests__/__snapshots__/Thread.test.js.snap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap index 909caaed7..44aebeaea 100644 --- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap +++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap @@ -1895,7 +1895,6 @@ exports[`Thread should match thread snapshot 1`] = ` } >