diff --git a/packages/mobile/polyfills/OdinBlob.ts b/packages/mobile/polyfills/OdinBlob.ts index 48fffab5..56936a36 100644 --- a/packages/mobile/polyfills/OdinBlob.ts +++ b/packages/mobile/polyfills/OdinBlob.ts @@ -249,7 +249,7 @@ class Blob { } } -export const getExtensionForMimeType = (mimeType: string | undefined | null) => { +const getExtensionForMimeType = (mimeType: string | undefined | null) => { if (!mimeType) return 'bin'; return mimeType === 'audio/mpeg' ? 'mp3' diff --git a/packages/mobile/src/components/Feed/Interacts/PostActionModal.tsx b/packages/mobile/src/components/Feed/Interacts/PostActionModal.tsx index 2ef1024a..3260e8bc 100644 --- a/packages/mobile/src/components/Feed/Interacts/PostActionModal.tsx +++ b/packages/mobile/src/components/Feed/Interacts/PostActionModal.tsx @@ -5,7 +5,7 @@ import { ChannelDefinitionVm } from '../../../hooks/feed/channels/useChannels'; import { BottomSheetModalMethods } from '@gorhom/bottom-sheet/lib/typescript/types'; import { useDarkMode } from '../../../hooks/useDarkMode'; import { Backdrop } from '../../ui/Modal/Backdrop'; -import { BottomSheetModal, BottomSheetScrollView, BottomSheetView } from '@gorhom/bottom-sheet'; +import { BottomSheetModal, BottomSheetView } from '@gorhom/bottom-sheet'; import { Colors } from '../../../app/Colors'; import { Platform } from 'react-native'; import { OwnerActions } from '../Meta/OwnerAction'; diff --git a/packages/mobile/src/components/ui/OdinImage/OdinImage.tsx b/packages/mobile/src/components/ui/OdinImage/OdinImage.tsx index 8e9dffa3..baf3521c 100644 --- a/packages/mobile/src/components/ui/OdinImage/OdinImage.tsx +++ b/packages/mobile/src/components/ui/OdinImage/OdinImage.tsx @@ -109,6 +109,7 @@ export const OdinImage = memo( if (enableZoom) { return ( - - + ); } ); diff --git a/packages/mobile/src/components/ui/OdinImage/hooks/useImage.tsx b/packages/mobile/src/components/ui/OdinImage/hooks/useImage.tsx index da159dac..22dd94d9 100644 --- a/packages/mobile/src/components/ui/OdinImage/hooks/useImage.tsx +++ b/packages/mobile/src/components/ui/OdinImage/hooks/useImage.tsx @@ -1,4 +1,4 @@ -import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { QueryClient, useQuery, useQueryClient } from '@tanstack/react-query'; import { ImageSize, TargetDrive, ImageContentType, SystemFileType } from '@homebase-id/js-lib/core'; import { exists } from 'react-native-fs'; @@ -10,12 +10,41 @@ import { getDecryptedMediaUrlOverPeer, } from '../../../../provider/image/RNExternalMediaProvider'; -interface ImageData { +export interface ImageData { url: string; naturalSize?: ImageSize; type?: ImageContentType; } +const roundToNearest25 = (value: number) => Math.round(value / 25) * 25; +const queryKeyBuilder = ( + odinId: string | undefined, + imageFileId: string | undefined, + imageFileKey: string | undefined, + imageDrive: TargetDrive | undefined, + size?: ImageSize, + lastModified?: number +) => { + const queryKey = [ + 'image', + odinId || '', + imageDrive?.alias, + imageFileId?.replaceAll('-', ''), + imageFileKey, + ]; + + if (size) { + // We round the size to the nearest 25 to avoid having too many different sizes in cache + queryKey.push(`${roundToNearest25(size.pixelHeight)}x${roundToNearest25(size?.pixelWidth)}`); + } + + if (lastModified) { + queryKey.push(lastModified + ''); + } + + return queryKey; +}; + const useImage = (props?: { odinId?: string; imageFileId?: string | undefined; @@ -45,35 +74,6 @@ const useImage = (props?: { const localHost = dotYouClient.getIdentity(); // This is the identity of the user - const roundToNearest25 = (value: number) => Math.round(value / 25) * 25; - const queryKeyBuilder = ( - odinId: string | undefined, - imageFileId: string | undefined, - imageFileKey: string | undefined, - imageDrive: TargetDrive | undefined, - size?: ImageSize, - lastModified?: number - ) => { - const queryKey = [ - 'image', - odinId || '', - imageDrive?.alias, - imageFileId?.replaceAll('-', ''), - imageFileKey, - ]; - - if (size) { - // We round the size to the nearest 25 to avoid having too many different sizes in cache - queryKey.push(`${roundToNearest25(size.pixelHeight)}x${roundToNearest25(size?.pixelWidth)}`); - } - - if (lastModified) { - queryKey.push(lastModified + ''); - } - - return queryKey; - }; - const getCachedImages = ( odinId: string | undefined, imageFileId: string, @@ -367,4 +367,17 @@ const useImage = (props?: { }; }; +export const insertImageIntoCache = ( + queryClient: QueryClient, + odinId: string | undefined, + imageFileId: string, + imageFileKey: string, + imageDrive: TargetDrive, + size: ImageSize | undefined, + imageData: ImageData +) => { + const queryKey = queryKeyBuilder(odinId, imageFileId, imageFileKey, imageDrive, size); + queryClient.setQueryData(queryKey, imageData); +}; + export default useImage; diff --git a/packages/mobile/src/hooks/chat/useChatMessage.ts b/packages/mobile/src/hooks/chat/useChatMessage.ts index 20e81732..6d1f52dd 100644 --- a/packages/mobile/src/hooks/chat/useChatMessage.ts +++ b/packages/mobile/src/hooks/chat/useChatMessage.ts @@ -9,6 +9,7 @@ import { import { HomebaseFile, + ImageContentType, NewHomebaseFile, NewPayloadDescriptor, RichText, @@ -26,6 +27,7 @@ import { } from '../../provider/chat/ChatProvider'; import { ImageSource } from '../../provider/image/RNImageProvider'; import { + ChatDrive, ConversationWithYourselfId, UnifiedConversation, } from '../../provider/chat/ConversationProvider'; @@ -34,6 +36,8 @@ import { getSynchronousDotYouClient } from './getSynchronousDotYouClient'; import { useErrors, addError, generateClientError } from '../errors/useErrors'; import { LinkPreview } from '@homebase-id/js-lib/media'; import { insertNewMessage } from './useChatMessages'; +import { insertImageIntoCache } from '../../components/ui/OdinImage/hooks/useImage'; +import { copyFileIntoCache } from '../../utils/utils'; import { addLogs } from '../../provider/log/logger'; const sendMessage = async ({ @@ -107,15 +111,15 @@ const sendMessage = async ({ newChat.fileMetadata.appData.uniqueId ) ? ({ - ...msg, - fileMetadata: { - ...msg?.fileMetadata, - payloads: (msg?.fileMetadata.payloads?.map((payload) => ({ - ...payload, - uploadProgress: { phase, progress }, - })) || []) as NewPayloadDescriptor, - }, - } as HomebaseFile) + ...msg, + fileMetadata: { + ...msg?.fileMetadata, + payloads: (msg?.fileMetadata.payloads?.map((payload) => ({ + ...payload, + uploadProgress: { phase, progress }, + })) || []) as NewPayloadDescriptor, + }, + } as HomebaseFile) : msg ), })), @@ -127,11 +131,22 @@ const sendMessage = async ({ ); }; + const pendingFiles = await Promise.all( + (files || [])?.map(async (file) => { + const newFilePath = await copyFileIntoCache( + file.uri || (file.filepath as string), + file.type || undefined + ); + + return { ...file, filepath: newFilePath, uri: newFilePath }; + }) + ); + const uploadResult = await uploadChatMessage( dotYouClient, newChat, recipients, - files, + pendingFiles, linkPreviews, recipients.length > 1 ? conversationContent.title @@ -148,17 +163,52 @@ const sendMessage = async ({ newChat.fileMetadata.appData.previewThumbnail = uploadResult.previewThumbnail; newChat.fileMetadata.appData.content.deliveryStatus = uploadResult.chatDeliveryStatus || ChatDeliveryStatus.Sent; - newChat.fileMetadata.payloads = files?.map((file, index) => ({ - key: `chat_mbl${index}`, - contentType: file.type || undefined, - pendingFile: - file.filepath || file.uri - ? (new OdinBlob((file.uri || file.filepath) as string, { - type: file.type || undefined, - }) as unknown as Blob) - : undefined, - })); + // Insert images into useImage cache: + const fileMetadataPayloads: NewPayloadDescriptor[] = await Promise.all( + (files || [])?.map(async (file, index) => { + const key = `chat_mbl${index}`; + + try { + if (file.type?.startsWith('image/')) { + const cachedImagePath = await copyFileIntoCache( + file.uri || file.filepath || '', + file.type + ); + + insertImageIntoCache( + queryClient, + undefined, + uploadResult.file.fileId, + key, + ChatDrive, + undefined, + { + url: cachedImagePath, + type: (file.type as ImageContentType) || undefined, + naturalSize: { + pixelWidth: file.width, + pixelHeight: file.height, + }, + } + ); + } + } catch {} + + return { + key, + contentType: file.type || undefined, + pendingFile: + file.filepath || file.uri + ? (new OdinBlob((file.uri || file.filepath) as string, { + type: file.type || undefined, + }) as unknown as Blob) + : undefined, + }; + }) + ); + + newChat.fileMetadata.payloads = fileMetadataPayloads; return newChat; }; @@ -201,11 +251,11 @@ export const getSendChatMessageMutationOptions: (queryClient: QueryClient) => Us previewThumbnail: files && files.length === 1 ? { - contentType: files[0].type as string, - content: files[0].uri || files[0].filepath || '', - pixelWidth: files[0].width, - pixelHeight: files[0].height, - } + contentType: files[0].type as string, + content: files[0].uri || files[0].filepath || '', + pixelWidth: files[0].width, + pixelHeight: files[0].height, + } : undefined, }, payloads: files?.map((file, index) => ({ @@ -214,8 +264,8 @@ export const getSendChatMessageMutationOptions: (queryClient: QueryClient) => Us pendingFile: file.filepath || file.uri ? (new OdinBlob((file.uri || file.filepath) as string, { - type: file.type || undefined, - }) as unknown as Blob) + type: file.type || undefined, + }) as unknown as Blob) : undefined, })), }, @@ -270,16 +320,16 @@ export const getUpdateChatMessageMutationOptions: (queryClient: QueryClient) => }, { extistingMessages: - | InfiniteData< - { - searchResults: (HomebaseFile | null)[]; - cursorState: string; - queryTime: number; - includeMetadataHeader: boolean; - }, - unknown - > - | undefined; + | InfiniteData< + { + searchResults: (HomebaseFile | null)[]; + cursorState: string; + queryTime: number; + includeMetadataHeader: boolean; + }, + unknown + > + | undefined; existingMessage: HomebaseFile | undefined; } > = (queryClient) => ({ @@ -354,13 +404,13 @@ export const useChatMessage = (props?: { const getMessageByUniqueId = async (conversationId: string | undefined, messageId: string) => { const extistingMessages = conversationId ? queryClient.getQueryData< - InfiniteData<{ - searchResults: (HomebaseFile | null)[]; - cursorState: string; - queryTime: number; - includeMetadataHeader: boolean; - }> - >(['chat-messages', conversationId]) + InfiniteData<{ + searchResults: (HomebaseFile | null)[]; + cursorState: string; + queryTime: number; + includeMetadataHeader: boolean; + }> + >(['chat-messages', conversationId]) : undefined; if (extistingMessages) { diff --git a/packages/mobile/src/provider/image/RNThumbnailProvider.ts b/packages/mobile/src/provider/image/RNThumbnailProvider.ts index 5eb802f2..cf2af3dd 100644 --- a/packages/mobile/src/provider/image/RNThumbnailProvider.ts +++ b/packages/mobile/src/provider/image/RNThumbnailProvider.ts @@ -10,8 +10,8 @@ import { Platform } from 'react-native'; import { CachesDirectoryPath, copyFile, readFile, stat, unlink } from 'react-native-fs'; import ImageResizer, { ResizeFormat } from '@bam.tech/react-native-image-resizer'; import { ImageSource } from './RNImageProvider'; -import { getExtensionForMimeType, OdinBlob } from '../../../polyfills/OdinBlob'; -import { isBase64ImageURI } from '../../utils/utils'; +import { OdinBlob } from '../../../polyfills/OdinBlob'; +import { isBase64ImageURI, getExtensionForMimeType } from '../../utils/utils'; export const baseThumbSizes: ThumbnailInstruction[] = [ { quality: 75, width: 250, height: 250 }, @@ -51,8 +51,7 @@ export const createThumbnails = async ( additionalThumbnails: ThumbnailFile[]; }> => { if (!photo.filepath && !photo.uri) throw new Error('No filepath found in image source'); - let copyOfSourcePath = photo.filepath || photo.uri as string; - + let copyOfSourcePath = photo.filepath || (photo.uri as string); if (!isBase64ImageURI(copyOfSourcePath)) { // We take a copy of the file, as it can be a virtual file that is not accessible by the native code; Eg: ImageResizer diff --git a/packages/mobile/src/utils/utils.ts b/packages/mobile/src/utils/utils.ts index 889b786f..fa5907d3 100644 --- a/packages/mobile/src/utils/utils.ts +++ b/packages/mobile/src/utils/utils.ts @@ -8,78 +8,76 @@ import { ImageSource } from '../provider/image/RNImageProvider'; //https://stackoverflow.com/a/21294619/15538463 export function millisToMinutesAndSeconds(millis: number | undefined): string { - if (!millis) return '0:00'; - const minutes = Math.floor(millis / 60000); - const seconds = Number(((millis % 60000) / 1000).toFixed(0)); - return seconds === 60 - ? minutes + 1 + ':00' - : minutes + ':' + (seconds < 10 ? '0' : '') + seconds; + if (!millis) return '0:00'; + const minutes = Math.floor(millis / 60000); + const seconds = Number(((millis % 60000) / 1000).toFixed(0)); + return seconds === 60 ? minutes + 1 + ':00' : minutes + ':' + (seconds < 10 ? '0' : '') + seconds; } export async function openURL(url: string): Promise { - if (!url) return; - url = url.startsWith('http') ? url : `https://${url}`; - if (await InAppBrowser.isAvailable()) { - await InAppBrowser.open(url, { - enableUrlBarHiding: false, - enableDefaultShare: false, - animated: true, - }); - } else Linking.openURL(url); + if (!url) return; + url = url.startsWith('http') ? url : `https://${url}`; + if (await InAppBrowser.isAvailable()) { + await InAppBrowser.open(url, { + enableUrlBarHiding: false, + enableDefaultShare: false, + animated: true, + }); + } else Linking.openURL(url); } export function extractUrls(text: string): string[] { - const urlRegex = /(https?:\/\/[^\s]+)/g; - return text.match(urlRegex) || []; + const urlRegex = /(https?:\/\/[^\s]+)/g; + return text.match(urlRegex) || []; } // Calculate the scaled dimensions of an image export function calculateScaledDimensions( - pixelWidth: number, - pixelHeight: number, - maxSize: { width: number; height: number } + pixelWidth: number, + pixelHeight: number, + maxSize: { width: number; height: number } ) { - const maxWidth = maxSize.width; - const maxHeight = maxSize.height; - - // Add a default value for pixelWidth and pixelHeight if the values are zero - if (pixelHeight === 0) { - pixelHeight = 300; - } - if (pixelWidth === 0) { - pixelWidth = 300; - } - - let newWidth, newHeight; - - // Check if the width needs to be scaled down - if (pixelWidth > maxWidth) { - newWidth = maxWidth; - newHeight = (pixelHeight * maxWidth) / pixelWidth; - } else { - newWidth = pixelWidth; - newHeight = pixelHeight; - } - - // If after scaling the height exceeds the maxHeight, scale down the height - if (newHeight > maxHeight) { - newHeight = maxHeight; - newWidth = (pixelWidth * maxHeight) / pixelHeight; - } - - return { width: newWidth, height: newHeight }; + const maxWidth = maxSize.width; + const maxHeight = maxSize.height; + + // Add a default value for pixelWidth and pixelHeight if the values are zero + if (pixelHeight === 0) { + pixelHeight = 300; + } + if (pixelWidth === 0) { + pixelWidth = 300; + } + + let newWidth, newHeight; + + // Check if the width needs to be scaled down + if (pixelWidth > maxWidth) { + newWidth = maxWidth; + newHeight = (pixelHeight * maxWidth) / pixelWidth; + } else { + newWidth = pixelWidth; + newHeight = pixelHeight; + } + + // If after scaling the height exceeds the maxHeight, scale down the height + if (newHeight > maxHeight) { + newHeight = maxHeight; + newWidth = (pixelWidth * maxHeight) / pixelHeight; + } + + return { width: newWidth, height: newHeight }; } export function getPayloadSize(size: number): string { - if (size < 1024) { - return `${size.toFixed(0)} Bytes`; - } else if (size < 1024 * 1024) { - return `${(size / 1024).toFixed(0)} KB`; - } else if (size < 1024 * 1024 * 1024) { - return `${(size / (1024 * 1024)).toFixed(0)} MB`; - } else { - return `${(size / (1024 * 1024 * 1024)).toFixed(0)} GB`; - } + if (size < 1024) { + return `${size.toFixed(0)} Bytes`; + } else if (size < 1024 * 1024) { + return `${(size / 1024).toFixed(0)} KB`; + } else if (size < 1024 * 1024 * 1024) { + return `${(size / (1024 * 1024)).toFixed(0)} MB`; + } else { + return `${(size / (1024 * 1024 * 1024)).toFixed(0)} GB`; + } } // This is needed when some files has file:// already prefixed with it @@ -87,96 +85,119 @@ export function getPayloadSize(size: number): string { // decode the URI component // see: https://github.com/react-native-documents/document-picker/issues/350#issuecomment-705437360 export function fixDocumentURI(url: string): string { - const prefixFile = 'file://'; - if (url.startsWith(prefixFile)) { - url = url.substring(prefixFile.length); - url = decodeURI(url); - } - return url; - + const prefixFile = 'file://'; + if (url.startsWith(prefixFile)) { + url = url.substring(prefixFile.length); + url = decodeURI(url); + } + return url; } export async function fixContentURI(url: string, format?: string): Promise { - if (url.startsWith('content://') && format) { - const destPath = `${CachesDirectoryPath}/${getNewId()}.${format}`; - await copyFile(url, destPath); - return `file://${destPath}`; - } - return decodeURI(url); + if (url.startsWith('content://') && format) { + const destPath = `${CachesDirectoryPath}/${getNewId()}.${format}`; + await copyFile(url, destPath); + return `file://${destPath}`; + } + return decodeURI(url); +} + +export async function copyFileIntoCache(url: string, contentType?: string): Promise { + if (!isBase64ImageURI(url)) { + // We take a copy of the file, as it can be a virtual file that is not accessible by the native code; Eg: ImageResizer + const targetPath = `file://${CachesDirectoryPath}/${getNewId()}${contentType ? `.${getExtensionForMimeType(contentType)}` : ''}`; + await copyFile(url, targetPath); + + return targetPath; + } + return url; } // Utility function to convert Image.getSize to a promise -export const getImageSize = (uri: string): Promise<{ width: number, height: number }> => { - return new Promise((resolve) => { - Image.getSize( - uri, - (width, height) => resolve({ width, height }), - (error) => { - console.error('Error getting image size', error); - resolve({ width: 500, height: 500 }); - } - ); - }); +export const getImageSize = (uri: string): Promise<{ width: number; height: number }> => { + return new Promise((resolve) => { + Image.getSize( + uri, + (width, height) => resolve({ width, height }), + (error) => { + console.error('Error getting image size', error); + resolve({ width: 500, height: 500 }); + } + ); + }); }; // Flattens all pages, sorts descending and slice on the max number expected export const flattenInfinteData = ( - rawData: - | InfiniteData<{ - results: T[]; - cursorState: unknown; - }> - | undefined, - pageSize?: number, - sortFn?: (a: T, b: T) => number + rawData: + | InfiniteData<{ + results: T[]; + cursorState: unknown; + }> + | undefined, + pageSize?: number, + sortFn?: (a: T, b: T) => number ) => { - return (rawData?.pages - .flatMap((page) => page?.results) - .filter((post) => !!post) - .sort(sortFn) - .slice(0, pageSize ? rawData?.pages.length * pageSize : undefined) || []) as T[]; + return (rawData?.pages + .flatMap((page) => page?.results) + .filter((post) => !!post) + .sort(sortFn) + .slice(0, pageSize ? rawData?.pages.length * pageSize : undefined) || []) as T[]; }; export function isBase64ImageURI(url: string): boolean { - // Regular expression to check for a Base64 image URI - const base64ImagePattern = /^data:image\/(png|jpeg|jpg|gif|bmp|svg\+xml);base64,([A-Za-z0-9+/=]+)$/; + // Regular expression to check for a Base64 image URI + const base64ImagePattern = + /^data:image\/(png|jpeg|jpg|gif|bmp|svg\+xml);base64,([A-Za-z0-9+/=]+)$/; - // Test the given URL against the regex pattern - return base64ImagePattern.test(url); + // Test the given URL against the regex pattern + return base64ImagePattern.test(url); } // Regular expression for URL parsing export const URL_PATTERN = new RegExp( - /((http|https|ftp):\/\/)?(([a-zA-Z0-9\-_]+\.)+[a-zA-Z]{2,})(:\d+)?(\/[^\s]*)?/gi + /((http|https|ftp):\/\/)?(([a-zA-Z0-9\-_]+\.)+[a-zA-Z]{2,})(:\d+)?(\/[^\s]*)?/gi ); // Function to clean special characters export function cleanString(input: string): string { - // Regex to match and remove: - // 1. Control characters from \u0000 to \u001F and \u007F to \u009F - // 2. Unicode invisible characters like zero-width space, non-joiner, joiner, etc. - const cleanedString = input - // eslint-disable-next-line no-control-regex - .replace(/[\u0000-\u001F\u007F-\u009F\u200B-\u200D\uFEFF]/g, '') // Remove control and invisible characters - .trim(); // Remove leading/trailing whitespace - - return cleanedString; + // Regex to match and remove: + // 1. Control characters from \u0000 to \u001F and \u007F to \u009F + // 2. Unicode invisible characters like zero-width space, non-joiner, joiner, etc. + const cleanedString = input + // eslint-disable-next-line no-control-regex + .replace(/[\u0000-\u001F\u007F-\u009F\u200B-\u200D\uFEFF]/g, '') // Remove control and invisible characters + .trim(); // Remove leading/trailing whitespace + + return cleanedString; } - export function assetsToImageSource(assets: Asset[]): ImageSource[] { - return assets.map((value) => { - return { - height: value.height || 0, - width: value.width || 0, - name: value.fileName, - type: value.type && value.type === 'image/jpg' ? 'image/jpeg' : value.type, - uri: value.uri, - filename: value.fileName, - date: Date.parse(value.timestamp || new Date().toUTCString()), - filepath: value.originalPath, - id: value.id, - fileSize: value.fileSize, - }; - }); + return assets.map((value) => { + return { + height: value.height || 0, + width: value.width || 0, + name: value.fileName, + type: value.type && value.type === 'image/jpg' ? 'image/jpeg' : value.type, + uri: value.uri, + filename: value.fileName, + date: Date.parse(value.timestamp || new Date().toUTCString()), + filepath: value.originalPath, + id: value.id, + fileSize: value.fileSize, + }; + }); } + +export const getExtensionForMimeType = (mimeType: string | undefined | null) => { + if (!mimeType) return 'bin'; + return mimeType === 'audio/mpeg' + ? 'mp3' + : mimeType === 'image/svg+xml' + ? 'svg' + : mimeType === 'application/vnd.apple.mpegurl' + ? 'm3u8' + : mimeType === 'video/mp2t' + ? 'ts' + : mimeType.split('/')[1]; +};