UNPKG

stream-chat-react-native-core

Version:

The official React Native and Expo components for Stream Chat, a service for building chat applications

264 lines (225 loc) 8.15 kB
import { makeMutable, SharedValue } from 'react-native-reanimated'; import { Attachment, LocalMessage, StateStore, Unsubscribe, UserResponse } from 'stream-chat'; import { VideoPlayerPool } from './video-player-pool'; import { getGiphyMimeType } from '../components/Attachment/utils/getGiphyMimeType'; import { isVideoPlayerAvailable } from '../native'; import { FileTypes } from '../types/types'; import { getUrlOfImageAttachment } from '../utils/getUrlOfImageAttachment'; export type ImageGalleryAsset = { id: string; uri: string; channelId?: string; created_at?: string | Date; messageId?: string; mime_type?: string; original_height?: number; original_width?: number; thumb_url?: string; type?: string; user?: UserResponse | null; user_id?: string; }; const isViewableImageAttachment = (attachment: Attachment) => { return attachment.type === FileTypes.Image && !attachment.title_link && !attachment.og_scrape_url; }; const isViewableVideoAttachment = (attachment: Attachment) => { return attachment.type === FileTypes.Video && isVideoPlayerAvailable(); }; const isViewableGiphyAttachment = (attachment: Attachment) => { return attachment.type === FileTypes.Giphy; }; const stripQueryFromUrl = (url: string) => url.split('?')[0]; export type ImageGalleryState = { assets: ImageGalleryAsset[]; messages: LocalMessage[]; selectedAttachmentUrl?: string; currentIndex: number; requesterNode: number | null; }; const INITIAL_STATE: ImageGalleryState = { assets: [], currentIndex: 0, messages: [], requesterNode: null, selectedAttachmentUrl: undefined, }; export type ImageGalleryOptions = { autoPlayVideo?: boolean; giphyVersion?: keyof NonNullable<Attachment['giphy']>; }; const INITIAL_IMAGE_GALLERY_OPTIONS: ImageGalleryOptions = { autoPlayVideo: false, giphyVersion: 'fixed_height', }; export class ImageGalleryStateStore { state: StateStore<ImageGalleryState>; options: ImageGalleryOptions; videoPlayerPool: VideoPlayerPool; /** * SharedValue mirror of `state.currentIndex` for worklet consumers (gesture * handlers, animated styles). Maintained in lockstep with the setter and the * reset path in `clear()`. Reads from worklets bypass the JS bridge entirely; * any code path that updates `currentIndex` must keep this mirror in sync. */ currentIndexShared: SharedValue<number>; constructor(options: Partial<ImageGalleryOptions> = {}) { this.options = { ...INITIAL_IMAGE_GALLERY_OPTIONS, ...options }; this.state = new StateStore<ImageGalleryState>(INITIAL_STATE); this.videoPlayerPool = new VideoPlayerPool(); this.currentIndexShared = makeMutable(INITIAL_STATE.currentIndex); } // Getters get messages() { return this.state.getLatestValue().messages; } get selectedAttachmentUrl() { return this.state.getLatestValue().selectedAttachmentUrl; } get requesterNode() { return this.state.getLatestValue().requesterNode; } get attachmentsWithMessage() { const messages = this.messages; const attachmentsWithMessage = messages .map((message) => ({ attachments: message.attachments ?? [], message, })) .filter(({ attachments }) => attachments.some((attachment) => { if (!attachment) { return false; } return ( isViewableImageAttachment(attachment) || isViewableVideoAttachment(attachment) || isViewableGiphyAttachment(attachment) ); }), ); return attachmentsWithMessage; } getAssetId(messageId: string, assetUrl: string) { return `photoId-${messageId}-${assetUrl}`; } get assets() { const attachmentsWithMessage = this.attachmentsWithMessage; const { giphyVersion = 'fixed_height' } = this.options; return attachmentsWithMessage.flatMap(({ message, attachments }) => { return attachments.map((attachment) => { const assetUrl = getUrlOfImageAttachment(attachment, giphyVersion) as string; const assetId = this.getAssetId(message?.id ?? '', assetUrl); const giphyURL = attachment.giphy?.[giphyVersion]?.url || attachment.thumb_url || attachment.image_url; const giphyMimeType = getGiphyMimeType(giphyURL ?? ''); return { channelId: message?.cid, created_at: message?.created_at, id: assetId, messageId: message?.id, mime_type: attachment.type === 'giphy' ? giphyMimeType : attachment.mime_type, original_height: attachment.original_height, original_width: attachment.original_width, thumb_url: attachment.thumb_url, type: attachment.type, uri: assetUrl, user: message?.user, user_id: message?.user_id, }; }); }); } // Setters set messages(messages: LocalMessage[]) { this.state.partialNext({ messages }); } set selectedAttachmentUrl(selectedAttachmentUrl: string | undefined) { this.state.partialNext({ selectedAttachmentUrl }); } set currentIndex(currentIndex: number) { const previousIndex = this.state.getLatestValue().currentIndex; this.state.partialNext({ currentIndex }); this.currentIndexShared.value = currentIndex; // When the user moves off the current slide, pause whatever video was // playing. Moved here from a useEffect in ImageGallery so the invariant // lives next to the state it depends on — and the parent no longer needs // to subscribe to currentIndex purely to drive this side effect. // `clear()` is the other reset path; it bypasses this setter but already // pauses everything through `videoPlayerPool.clear()`. if (previousIndex !== currentIndex) { const activePlayer = this.videoPlayerPool.getActivePlayer(); if (activePlayer) { activePlayer.pause(); } } } set requesterNode(requesterNode: number | null) { this.state.partialNext({ requesterNode }); } // APIs for managing messages appendMessages = (messages: LocalMessage[]) => { this.state.partialNext({ messages: [...this.messages, ...messages] }); }; removeMessages = (messages: LocalMessage[]) => { this.state.partialNext({ messages: this.messages.filter((message) => !messages.includes(message)), }); }; openImageGallery = ({ messages, requesterNode = null, selectedAttachmentUrl, }: { messages: LocalMessage[]; requesterNode?: number | null; selectedAttachmentUrl?: string; }) => { this.state.partialNext({ messages, requesterNode, selectedAttachmentUrl }); }; subscribeToMessages = () => { const unsubscribe = this.state.subscribeWithSelector( (currentValue) => ({ messages: currentValue.messages, }), () => { const assets = this.assets; this.state.partialNext({ assets }); }, ); return unsubscribe; }; subscribeToSelectedAttachmentUrl = () => { const unsubscribe = this.state.subscribeWithSelector( (currentValue) => ({ messages: currentValue.messages, selectedAttachmentUrl: currentValue.selectedAttachmentUrl, }), ({ selectedAttachmentUrl }) => { if (!selectedAttachmentUrl) { return; } const index = this.assets.findIndex( (asset) => stripQueryFromUrl(asset.uri) === stripQueryFromUrl(selectedAttachmentUrl ?? ''), ); // Route through the setter so currentIndexShared stays in sync. this.currentIndex = index === -1 ? 0 : index; }, ); return unsubscribe; }; registerSubscriptions = () => { const subscriptions: Unsubscribe[] = []; subscriptions.push(this.subscribeToMessages()); subscriptions.push(this.subscribeToSelectedAttachmentUrl()); return () => { subscriptions.forEach((subscription) => subscription()); this.videoPlayerPool.clear(); }; }; clear = () => { this.videoPlayerPool.clear(); this.state.partialNext(INITIAL_STATE); this.currentIndexShared.value = INITIAL_STATE.currentIndex; }; }