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
text/typescript
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;
};
}