diff --git a/.gitignore b/.gitignore index 515651d51..bd6cc571b 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,9 @@ google-services.json GoogleService-Info.plist .env .env.local + +AGENTS.md + +eslint.config.js +yarn.lock +settings.json \ No newline at end of file diff --git a/jest.config.js b/jest.config.js index 7b698d81a..f83a1c87a 100644 --- a/jest.config.js +++ b/jest.config.js @@ -4,6 +4,11 @@ module.exports = { moduleNameMapper: { '^@/(.*)$': '/src/$1', }, + transform: { + '^.+\\.(js|jsx|ts|tsx)$': ['babel-jest', { configFile: './babel.config.js' }], + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + extensionsToTreatAsEsm: ['.ts', '.tsx'], transformIgnorePatterns: [ 'node_modules/(?!(jest-)?@?react-native|@react-native-community|@react-navigation|@reduxjs|immer)', ], diff --git a/package.json b/package.json index 1c28b6107..422ceaebe 100644 --- a/package.json +++ b/package.json @@ -190,4 +190,4 @@ "storybook": "8.6.15" } } -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0138a93d3..103640c59 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1471,7 +1471,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {node: '>=0.10.0'} + engines: {'0': node >=0.10.0} '@expo/cli@0.22.26': resolution: {integrity: sha512-I689wc8Fn/AX7aUGiwrh3HnssiORMJtR2fpksX+JIe8Cj/EDleblYMSwRPd0025wrwOV9UN1KM/RuEt/QjCS3Q==} @@ -7097,7 +7097,7 @@ snapshots: '@babel/helper-annotate-as-pure@7.27.1': dependencies: - '@babel/types': 7.27.1 + '@babel/types': 7.28.6 '@babel/helper-annotate-as-pure@7.27.3': dependencies: @@ -7267,7 +7267,7 @@ snapshots: '@babel/helper-skip-transparent-expression-wrappers@7.27.1': dependencies: '@babel/traverse': 7.27.1 - '@babel/types': 7.27.1 + '@babel/types': 7.28.6 transitivePeerDependencies: - supports-color @@ -7294,7 +7294,7 @@ snapshots: '@babel/highlight@7.25.9': dependencies: - '@babel/helper-validator-identifier': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 chalk: 2.4.2 js-tokens: 4.0.0 picocolors: 1.1.1 @@ -7612,7 +7612,7 @@ snapshots: dependencies: '@babel/core': 7.27.1 '@babel/helper-plugin-utils': 7.27.1 - '@babel/template': 7.27.1 + '@babel/template': 7.28.6 '@babel/plugin-transform-computed-properties@7.28.6(@babel/core@7.27.1)': dependencies: @@ -7916,7 +7916,7 @@ snapshots: '@babel/helper-module-imports': 7.27.1 '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.27.1) - '@babel/types': 7.27.1 + '@babel/types': 7.28.6 transitivePeerDependencies: - supports-color @@ -9144,7 +9144,7 @@ snapshots: '@jest/source-map@29.6.3': dependencies: - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/trace-mapping': 0.3.31 callsites: 3.1.0 graceful-fs: 4.2.11 @@ -10223,16 +10223,16 @@ snapshots: '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.27.1 + '@babel/types': 7.28.6 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.27.1 - '@babel/types': 7.27.1 + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 '@types/babel__traverse@7.20.7': dependencies: - '@babel/types': 7.27.1 + '@babel/types': 7.28.6 '@types/graceful-fs@4.1.9': dependencies: @@ -10456,7 +10456,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.0 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -12503,7 +12503,7 @@ snapshots: istanbul-lib-instrument@6.0.3: dependencies: '@babel/core': 7.27.1 - '@babel/parser': 7.27.1 + '@babel/parser': 7.28.6 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 semver: 7.7.1 @@ -12518,7 +12518,7 @@ snapshots: istanbul-lib-source-maps@4.0.1: dependencies: - debug: 4.4.0 + debug: 4.4.3 istanbul-lib-coverage: 3.2.2 source-map: 0.6.1 transitivePeerDependencies: @@ -13572,7 +13572,7 @@ snapshots: parse-json@5.2.0: dependencies: - '@babel/code-frame': 7.27.1 + '@babel/code-frame': 7.28.6 error-ex: 1.3.2 json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 @@ -14967,7 +14967,7 @@ snapshots: v8-to-istanbul@9.3.0: dependencies: - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/trace-mapping': 0.3.31 '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 diff --git a/src/components-next/common/slider/Slider.tsx b/src/components-next/common/slider/Slider.tsx index ac8b5e251..746ff68ee 100644 --- a/src/components-next/common/slider/Slider.tsx +++ b/src/components-next/common/slider/Slider.tsx @@ -54,6 +54,9 @@ export const Slider = (props: SliderProps) => { useAnimatedReaction( () => currentPosition.value, (next, _prev) => { + if (totalDuration.value === 0) { + return; + } translationX.value = withSpring( interpolate(next, [0, totalDuration.value], [0, sliderMaxWidth.value - 16]), { diff --git a/src/constants/index.ts b/src/constants/index.ts index dd1c25894..628ce587a 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -33,7 +33,7 @@ export const AUDIO_FORMATS = { M4A: 'audio/m4a', }; -export const MAXIMUM_FILE_UPLOAD_SIZE = 20; +export const MAXIMUM_FILE_UPLOAD_SIZE = 50; export const CONVERSATION_STATUSES = [ { diff --git a/src/i18n/af.json b/src/i18n/af.json index 08046694a..dbf35641f 100644 --- a/src/i18n/af.json +++ b/src/i18n/af.json @@ -123,7 +123,7 @@ "TYPING": "typing", "ALL_CONVERSATION_LOADED": "All conversations loaded", "ASSIGN": "Assign conversation", - "FILE_SIZE_LIMIT": "File exceeds the 5MB attachment limit", + "FILE_SIZE_LIMIT": "File exceeds the 50MB attachment limit", "SHARE": "Share conversation", "DETAILS": "Details", "LABELS": "Conversation labels", diff --git a/src/i18n/az.json b/src/i18n/az.json index 15884c14b..aea3ec15f 100644 --- a/src/i18n/az.json +++ b/src/i18n/az.json @@ -123,7 +123,7 @@ "TYPING": "typing", "ALL_CONVERSATION_LOADED": "All conversations loaded", "ASSIGN": "Assign conversation", - "FILE_SIZE_LIMIT": "File exceeds the 5MB attachment limit", + "FILE_SIZE_LIMIT": "File exceeds the 50MB attachment limit", "SHARE": "Share", "DETAILS": "Details", "LABELS": "Conversation labels", diff --git a/src/i18n/cs.json b/src/i18n/cs.json index 0ad94290f..4b33eabbd 100644 --- a/src/i18n/cs.json +++ b/src/i18n/cs.json @@ -123,7 +123,7 @@ "TYPING": "typing", "ALL_CONVERSATION_LOADED": "All conversations loaded", "ASSIGN": "Assign conversation", - "FILE_SIZE_LIMIT": "File exceeds the 5MB attachment limit", + "FILE_SIZE_LIMIT": "File exceeds the 50MB attachment limit", "SHARE": "Share conversation", "DETAILS": "Details", "LABELS": "Conversation labels", diff --git a/src/i18n/da.json b/src/i18n/da.json index 7ffd7484f..669b0953b 100644 --- a/src/i18n/da.json +++ b/src/i18n/da.json @@ -123,7 +123,7 @@ "TYPING": "typing", "ALL_CONVERSATION_LOADED": "All conversations loaded", "ASSIGN": "Assign conversation", - "FILE_SIZE_LIMIT": "File exceeds the 5MB attachment limit", + "FILE_SIZE_LIMIT": "File exceeds the 50MB attachment limit", "SHARE": "Share conversation", "DETAILS": "Details", "LABELS": "Conversation labels", diff --git a/src/i18n/el.json b/src/i18n/el.json index 84b61c37e..7db2db4ca 100644 --- a/src/i18n/el.json +++ b/src/i18n/el.json @@ -123,7 +123,7 @@ "TYPING": "typing", "ALL_CONVERSATION_LOADED": "All conversations loaded", "ASSIGN": "Assign conversation", - "FILE_SIZE_LIMIT": "File exceeds the 5MB attachment limit", + "FILE_SIZE_LIMIT": "File exceeds the 50MB attachment limit", "SHARE": "Share conversation", "DETAILS": "Details", "LABELS": "Conversation labels", diff --git a/src/i18n/en.json b/src/i18n/en.json index 53b1c687a..74da5cd0d 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -145,7 +145,7 @@ "TYPING": "typing", "ALL_CONVERSATION_LOADED": "All conversations loaded", "ASSIGN": "Assign conversation", - "FILE_SIZE_LIMIT": "File exceeds the 5MB attachment limit", + "FILE_SIZE_LIMIT": "File exceeds the 50MB attachment limit", "SHARE": "Share", "DETAILS": "Details", "LABELS": "Conversation labels", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index b165f2782..f31ced43f 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -123,7 +123,7 @@ "TYPING": "टाइपिंग", "ALL_CONVERSATION_LOADED": "सभी वार्तालाप लोड हो गए", "ASSIGN": "Assign conversation", - "FILE_SIZE_LIMIT": "File exceeds the 5MB attachment limit", + "FILE_SIZE_LIMIT": "File exceeds the 50MB attachment limit", "SHARE": "Share conversation", "DETAILS": "Details", "LABELS": "Conversation labels", diff --git a/src/i18n/hr.json b/src/i18n/hr.json index 6635480ea..ad8ad1e59 100644 --- a/src/i18n/hr.json +++ b/src/i18n/hr.json @@ -123,7 +123,7 @@ "TYPING": "typing", "ALL_CONVERSATION_LOADED": "All conversations loaded", "ASSIGN": "Assign conversation", - "FILE_SIZE_LIMIT": "File exceeds the 5MB attachment limit", + "FILE_SIZE_LIMIT": "File exceeds the 50MB attachment limit", "SHARE": "Share", "DETAILS": "Details", "LABELS": "Conversation labels", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index bdbf44b62..9b28c38a7 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -123,7 +123,7 @@ "TYPING": "typing", "ALL_CONVERSATION_LOADED": "All conversations loaded", "ASSIGN": "Assign conversation", - "FILE_SIZE_LIMIT": "File exceeds the 5MB attachment limit", + "FILE_SIZE_LIMIT": "File exceeds the 50MB attachment limit", "SHARE": "Share conversation", "DETAILS": "Details", "LABELS": "Conversation labels", diff --git a/src/i18n/ms.json b/src/i18n/ms.json index 439abe483..6536b32f8 100644 --- a/src/i18n/ms.json +++ b/src/i18n/ms.json @@ -123,7 +123,7 @@ "TYPING": "typing", "ALL_CONVERSATION_LOADED": "All conversations loaded", "ASSIGN": "Assign conversation", - "FILE_SIZE_LIMIT": "File exceeds the 5MB attachment limit", + "FILE_SIZE_LIMIT": "File exceeds the 50MB attachment limit", "SHARE": "Share conversation", "DETAILS": "Details", "LABELS": "Conversation labels", diff --git a/src/i18n/no.json b/src/i18n/no.json index 5f915e613..7c5451d02 100644 --- a/src/i18n/no.json +++ b/src/i18n/no.json @@ -123,7 +123,7 @@ "TYPING": "typing", "ALL_CONVERSATION_LOADED": "All conversations loaded", "ASSIGN": "Assign conversation", - "FILE_SIZE_LIMIT": "File exceeds the 5MB attachment limit", + "FILE_SIZE_LIMIT": "File exceeds the 50MB attachment limit", "SHARE": "Share conversation", "DETAILS": "Details", "LABELS": "Conversation labels", diff --git a/src/i18n/sh.json b/src/i18n/sh.json index 15884c14b..aea3ec15f 100644 --- a/src/i18n/sh.json +++ b/src/i18n/sh.json @@ -123,7 +123,7 @@ "TYPING": "typing", "ALL_CONVERSATION_LOADED": "All conversations loaded", "ASSIGN": "Assign conversation", - "FILE_SIZE_LIMIT": "File exceeds the 5MB attachment limit", + "FILE_SIZE_LIMIT": "File exceeds the 50MB attachment limit", "SHARE": "Share", "DETAILS": "Details", "LABELS": "Conversation labels", diff --git a/src/i18n/sl.json b/src/i18n/sl.json index 386069388..18e0072a4 100644 --- a/src/i18n/sl.json +++ b/src/i18n/sl.json @@ -123,7 +123,7 @@ "TYPING": "typing", "ALL_CONVERSATION_LOADED": "All conversations loaded", "ASSIGN": "Assign conversation", - "FILE_SIZE_LIMIT": "File exceeds the 5MB attachment limit", + "FILE_SIZE_LIMIT": "File exceeds the 50MB attachment limit", "SHARE": "Share", "DETAILS": "Details", "LABELS": "Conversation labels", diff --git a/src/i18n/sq.json b/src/i18n/sq.json index 15884c14b..aea3ec15f 100644 --- a/src/i18n/sq.json +++ b/src/i18n/sq.json @@ -123,7 +123,7 @@ "TYPING": "typing", "ALL_CONVERSATION_LOADED": "All conversations loaded", "ASSIGN": "Assign conversation", - "FILE_SIZE_LIMIT": "File exceeds the 5MB attachment limit", + "FILE_SIZE_LIMIT": "File exceeds the 50MB attachment limit", "SHARE": "Share", "DETAILS": "Details", "LABELS": "Conversation labels", diff --git a/src/i18n/sr.json b/src/i18n/sr.json index 08046694a..dbf35641f 100644 --- a/src/i18n/sr.json +++ b/src/i18n/sr.json @@ -123,7 +123,7 @@ "TYPING": "typing", "ALL_CONVERSATION_LOADED": "All conversations loaded", "ASSIGN": "Assign conversation", - "FILE_SIZE_LIMIT": "File exceeds the 5MB attachment limit", + "FILE_SIZE_LIMIT": "File exceeds the 50MB attachment limit", "SHARE": "Share conversation", "DETAILS": "Details", "LABELS": "Conversation labels", diff --git a/src/i18n/sv.json b/src/i18n/sv.json index edcdcb740..c0e692ff1 100644 --- a/src/i18n/sv.json +++ b/src/i18n/sv.json @@ -123,7 +123,7 @@ "TYPING": "skriver", "ALL_CONVERSATION_LOADED": "Alla konversationer laddade", "ASSIGN": "Tilldela konversation", - "FILE_SIZE_LIMIT": "File exceeds the 5MB attachment limit", + "FILE_SIZE_LIMIT": "File exceeds the 50MB attachment limit", "SHARE": "Share conversation", "DETAILS": "Details", "LABELS": "Conversation labels", diff --git a/src/i18n/ta.json b/src/i18n/ta.json index 146620b83..2f04695bb 100644 --- a/src/i18n/ta.json +++ b/src/i18n/ta.json @@ -123,7 +123,7 @@ "TYPING": "பதிவிடப்படுகிறது", "ALL_CONVERSATION_LOADED": "All conversations loaded", "ASSIGN": "Assign conversation", - "FILE_SIZE_LIMIT": "File exceeds the 5MB attachment limit", + "FILE_SIZE_LIMIT": "File exceeds the 50MB attachment limit", "SHARE": "Share conversation", "DETAILS": "Details", "LABELS": "Conversation labels", diff --git a/src/screens/chat-screen/components/message-components/AudioBubble.tsx b/src/screens/chat-screen/components/message-components/AudioBubble.tsx index 95acbe9b5..1a2c8eeb2 100644 --- a/src/screens/chat-screen/components/message-components/AudioBubble.tsx +++ b/src/screens/chat-screen/components/message-components/AudioBubble.tsx @@ -1,192 +1,745 @@ -import React, { useEffect, useMemo, useState, useCallback } from 'react'; -import { Platform, Pressable, View } from 'react-native'; -import { PlayBackType } from 'react-native-audio-recorder-player'; -import Animated, { FadeIn, FadeOut, useSharedValue } from 'react-native-reanimated'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Platform, Pressable, Text, View } from 'react-native'; +import Animated, { FadeIn, FadeOut, runOnJS, useSharedValue } from 'react-native-reanimated'; import Svg, { Path, Rect } from 'react-native-svg'; import * as Sentry from '@sentry/react-native'; +import { Audio, AVPlaybackStatus } from 'expo-av'; +import { useDispatch } from 'react-redux'; +import { useAppSelector } from '@/hooks'; +import { MESSAGE_VARIANTS } from '@/constants'; +import { Slider } from '@/components-next/common'; +import { Spinner } from '@/components-next/spinner'; +import { tailwind } from '@/theme'; +import { IconProps } from '@/types'; import { selectCurrentPlayingAudioSrc, setCurrentPlayingAudioSrc, } from '@/store/conversation/audioPlayerSlice'; - -import { tailwind } from '@/theme'; -import { IconProps } from '@/types'; -import { Icon, Slider } from '@/components-next/common'; -import { Spinner } from '@/components-next/spinner'; -import { pausePlayer, resumePlayer, seekTo, startPlayer, stopPlayer } from '../audio-recorder'; -import { MESSAGE_VARIANTS } from '@/constants'; -import { useDispatch } from 'react-redux'; -import { useAppSelector } from '@/hooks'; -// eslint-disable-next-line import/no-unresolved import { convertOggToWav } from '@/utils/audioConverter'; -// eslint-disable-next-line react/display-name -const PlayIcon = React.memo(({ fill, fillOpacity }: IconProps) => { - return ( - - - - ); -}); -// eslint-disable-next-line react/display-name -const PauseIcon = React.memo(({ fill, fillOpacity }: IconProps) => { - return ( - - - - - ); -}); +const formatTime = (ms: number) => { + const total = Math.max(0, Math.floor((ms || 0) / 1000)); + const mm = String(Math.floor(total / 60)).padStart(2, '0'); + const ss = String(total % 60).padStart(2, '0'); + return `${mm}:${ss}`; +}; + +const PlayIcon = React.memo(({ fill, fillOpacity }: IconProps) => ( + + + +)); + +const PauseIcon = React.memo(({ fill, fillOpacity }: IconProps) => ( + + + + +)); type AudioBubbleProps = { + id: string; audioSrc: string; variant: string; }; -type AudioPlayerProps = Pick & { - variant: string; +type QueueEntry = { + id: string; + getSrc: () => string; + getKey: () => string; + onLoading: (v: boolean) => void; + onEnded: () => void; + onStatus: (s: AVPlaybackStatus) => void; }; -// eslint-disable-next-line react/display-name -export const AudioBubblePlayer = React.memo((props: AudioPlayerProps) => { - const { audioSrc, variant } = props; +/** + * PlayerHub is a singleton audio manager responsible for handling all audio playback + * across multiple `AudioBubblePlayer` instances. It ensures only one audio track + * plays at a time and manages the lifecycle of `expo-av` Audio.Sound objects. + * This pattern helps centralize audio control, prevent concurrent playback issues, + * and manage resources efficiently. + */ +const PlayerHub = (() => { + let sound: Audio.Sound | null = null; // The currently active Audio.Sound instance. + let currentId: string | null = null; // The `idRef.current` of the currently playing AudioBubblePlayer. + let currentKey: string | null = null; // Unique key for the current audio source (id::src). + let currentSrc: string | null = null; // The audio source URI of the currently playing track. + + // Maps AudioBubblePlayer IDs to their registration entries. + const entries = new Map(); + // Maintains the order of AudioBubblePlayer registration, used for playNextAfter. + const order: string[] = []; + // Stores IDs of players that should not automatically play the next track. + const suppressAutoNext = new Set(); + + // A promise chain to sequentialize asynchronous audio operations, preventing race conditions. + let opChain: Promise = Promise.resolve(); + + /** + * Enqueues an asynchronous operation to be executed sequentially. + * This prevents race conditions when multiple audio operations are triggered concurrently. + * @param fn The asynchronous function to enqueue. + * @returns A promise that resolves when the enqueued function completes. + */ + const enqueue = (fn: () => Promise) => { + opChain = opChain.then(fn).catch((e) => Sentry.captureException(e)); // Log errors instead of swallowing. + return opChain; + }; + + /** + * Sets the audio mode for `expo-av`. This is crucial for controlling how audio interacts + * with the device's audio system (e.g., mixing with other apps, silent mode behavior). + * It's called before any playback to ensure consistent audio behavior. + */ + const safeSetAudioMode = async () => { + try { + await Audio.setAudioModeAsync({ + allowsRecordingIOS: false, + interruptionModeIOS: Audio.InterruptionModeIOS.DoNotMix, // Do not mix with other audio sources on iOS. + playsInSilentModeIOS: true, // Allow playback when the device is in silent mode on iOS. + staysActiveInBackground: false, // Audio should not stay active if app goes to background. + interruptionModeAndroid: Audio.InterruptionModeAndroid.DoNotMix, // Do not mix with other audio sources on Android. + shouldDuckAndroid: true, // Reduce volume of other audio when this audio plays on Android. + playThroughEarpieceAndroid: false, // Play through speaker, not earpiece, on Android. + }); + } catch (e) { + Sentry.captureException(e); + } + }; + + /** + * Detaches the playback status update listener and unloads the `Audio.Sound` instance. + * This frees up system resources and is part of the cleanup process. + */ + const detachAndUnload = async () => { + if (!sound) return; + try { + sound.setOnPlaybackStatusUpdate(null as any); // Remove status update listener. + } catch {} + try { + await sound.unloadAsync(); // Unload the sound from memory. + } catch {} + sound = null; // Clear the sound instance. + }; + + /** + * Stops the current playback and resets the position to 0, but does not unload the sound. + */ + const stopAndReset = async () => { + if (!sound) return; + try { + await sound.stopAsync(); // Stop playback. + } catch {} + try { + await sound.setPositionAsync(0); // Reset position to beginning. + } catch {} + }; + + /** + * Stops and unloads the current audio if its ID matches the provided ID. + * This is used when a specific `AudioBubblePlayer` is unmounting or its source changes. + * @param id The ID of the AudioBubblePlayer requesting the stop. + */ + const stopAndUnloadIfCurrent = async (id: string) => { + if (currentId !== id) return; // Only act if this ID is the current one. + currentId = null; + currentKey = null; + currentSrc = null; + await stopAndReset(); + await detachAndUnload(); + }; + + /** + * Forces all audio playback to stop and unloads the current `Audio.Sound` instance. + * This is primarily used for cleanup during Fast Refresh/HMR or when the player hub needs + * a complete reset. + */ + const forceStopAll = async () => { + currentId = null; + currentKey = null; + currentSrc = null; + try { + await stopAndReset(); + } catch {} + try { + await detachAndUnload(); + } catch {} + }; + + /** + * Registers an `AudioBubblePlayer` component with the PlayerHub. + * This allows the PlayerHub to manage its playback status and respond to global audio events. + * @param entry The QueueEntry object containing the player's details and callbacks. + */ + const register = (entry: QueueEntry) => { + if (!entries.has(entry.id)) order.push(entry.id); // Add to order if new. + entries.set(entry.id, entry); + }; + + /** + * Unregisters an `AudioBubblePlayer` component from the PlayerHub. + * Called when an `AudioBubblePlayer` unmounts. + * @param id The ID of the player to unregister. + */ + const unregister = (id: string) => { + entries.delete(id); + const idx = order.indexOf(id); + if (idx >= 0) order.splice(idx, 1); // Remove from order. + suppressAutoNext.delete(id); // Ensure no lingering auto-next suppression. + }; + + /** + * Prevents the given `AudioBubblePlayer` from automatically playing the next track + * in the sequence, specifically when its current track finishes. + * @param id The ID of the player to suppress. + */ + const suppressNextOnce = (id: string) => suppressAutoNext.add(id); + + /** + * Checks if the given `AudioBubblePlayer` ID matches the ID of the currently playing audio. + * @param id The ID to check. + * @returns True if the ID is current, false otherwise. + */ + const isCurrent = (id: string) => currentId === id; + /** + * Returns the unique key of the currently playing audio source. + * @returns The current audio key, or an empty string if nothing is playing. + */ + const getCurrentKey = () => currentKey ?? ''; + + /** + * Attaches a playback status update listener to the `Audio.Sound` instance. + * This listener updates the corresponding `AudioBubblePlayer` with the latest playback status, + * handles track completion, and triggers auto-play of the next track if applicable. + * @param s The Audio.Sound instance to attach the listener to. + */ + const attachStatusUpdates = (s: Audio.Sound) => { + s.setOnPlaybackStatusUpdate((st) => { + const activeId = currentId; + if (!activeId) return; // No active player. + + const entry = entries.get(activeId); + if (entry) entry.onStatus(st); // Update the specific player's status. + + if (!st.isLoaded) return; // Only proceed if the sound is loaded. + + if ((st as any).didJustFinish) { + const endedId = activeId; + const endedEntry = entries.get(endedId); + + endedEntry?.onLoading(false); // Stop loading indicator. + + // If auto-next was suppressed for this track, clear the flag and return. + if (suppressAutoNext.has(endedId)) { + suppressAutoNext.delete(endedId); + return; + } + + endedEntry?.onEnded(); // Notify the player that the track has ended. + } + }); + }; + + /** + * Initiates playback for a specific `AudioBubblePlayer` by its ID. + * Manages stopping any currently playing audio and loading the new audio source. + * @param id The ID of the `AudioBubblePlayer` to play. + */ + const playById = async (id: string) => { + const entry = entries.get(id); + if (!entry) return; // Player not registered. + + const src = entry.getSrc(); + const key = entry.getKey(); + if (!src || !key) return; // Invalid audio source. + + await safeSetAudioMode(); // Ensure correct audio mode is set. + + // If the requested audio is already the current one and has a sound instance, + // check its status and resume if paused, or simply return if already playing. + if (currentId === id && sound) { + try { + const st = await sound.getStatusAsync(); + if (st.isLoaded) { + currentSrc = src; + currentKey = key; + if ((st as any).isPlaying) { + entry.onLoading(false); // Already playing, so not loading. + return; + } + await sound.playAsync(); // Resume if paused. + entry.onLoading(false); + return; + } + } catch (e) { + Sentry.captureException(e); + } + } + + // If another audio is currently playing, stop and unload it before playing the new one. + if (currentId && currentId !== id) { + suppressNextOnce(currentId); // Prevent the old track from auto-playing the next. + const prev = entries.get(currentId); + prev?.onLoading(false); // Turn off loading for the previous track. + await stopAndReset(); + await detachAndUnload(); + } + + // Set the new audio as current. + currentId = id; + currentSrc = src; + currentKey = key; + + entry.onLoading(true); // Show loading indicator for the new track. + + try { + const s = new Audio.Sound(); + sound = s; // Assign the new sound instance. + attachStatusUpdates(s); // Attach status listener. + + // Load and play the new audio. + await s.loadAsync({ uri: src }, { shouldPlay: true, positionMillis: 0 }, true); + await s.setProgressUpdateIntervalAsync(250); // Set update interval for slider. + + entry.onLoading(false); // Hide loading indicator. + } catch (e) { + // Handle errors during loading/playback. + entry.onLoading(false); + await detachAndUnload(); + if (currentId === id) { + currentId = null; + currentKey = null; + currentSrc = null; + } + Sentry.captureException(e); + } + }; + + /** + * Pauses the currently playing audio. + */ + const pause = async () => { + if (!sound) return; + try { + await sound.pauseAsync(); + } catch (e) { + Sentry.captureException(e); + } + }; + + /** + * Resumes the currently paused audio. + */ + const resume = async () => { + if (!sound) return; + try { + await sound.playAsync(); + } catch (e) { + Sentry.captureException(e); + } + }; + + /** + * Seeks to a specific position in the currently playing audio. + * @param ms The position in milliseconds to seek to. + */ + const seekTo = async (ms: number) => { + if (!sound) return; + try { + await sound.setPositionAsync(Math.max(0, Math.floor(ms))); + } catch (e) { + Sentry.captureException(e); + } + }; + + /** + * Plays the next audio in the `order` queue if the current audio finishes. + * If there is no next audio, it stops and unloads the current one. + * @param id The ID of the audio that just finished. + */ + const playNextAfter = async (id: string) => { + if (currentId !== id) return; // Only proceed if this is the currently active audio. + + const idx = order.indexOf(id); + const nextId = idx >= 0 ? order[idx + 1] ?? null : null; // Get the next ID in the ordered list. + + if (!nextId) { + // No more tracks in the queue, so stop and unload. + await stopAndUnloadIfCurrent(id); + return; + } + + await playById(nextId); // Play the next track. + }; + + return { + register, + unregister, + enqueue, + playById, + pause, + resume, + seekTo, + playNextAfter, + suppressNextOnce, + stopAndUnloadIfCurrent, + forceStopAll, + isCurrent, + getCurrentKey, + }; +})(); + +// Assign the PlayerHub to a global variable to ensure it's a true singleton +// across different instances of the module, especially during hot module reloading. +const g: any = globalThis as any; +try { + // If a previous instance of PlayerHub exists, ensure it's cleaned up. + const prev = g.__AUDIO_BUBBLE_PLAYER_HUB__; + if (prev && typeof prev.forceStopAll === 'function') { + prev.enqueue(() => prev.forceStopAll()); + } +} catch {} +g.__AUDIO_BUBBLE_PLAYER_HUB__ = PlayerHub; +/** + * Best-effort cleanup on Fast Refresh / HMR (Hot Module Replacement). + * When the module is reloaded, this ensures that the previous PlayerHub instance + * is properly stopped to prevent multiple audio contexts or lingering playback. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const _m: any = typeof module !== 'undefined' ? (module as any) : null; +if (_m?.hot) { + _m.hot.dispose(() => { + try { + PlayerHub.enqueue(() => PlayerHub.forceStopAll()); + } catch {} + }); +} + +/** + * Probes an audio URI to determine its duration in milliseconds without playing it. + * This is used to get the total duration for the slider before playback begins. + * @param uri The URI of the audio file. + * @returns A promise that resolves with the duration in milliseconds, or 0 if an error occurs. + */ +const probeDurationMs = async (uri: string): Promise => { + try { + const { sound } = await Audio.Sound.createAsync({ uri }, { shouldPlay: false }, undefined, false); + const st = await sound.getStatusAsync(); + await sound.unloadAsync(); // Unload immediately after getting status. + if (st.isLoaded && typeof (st as any).durationMillis === 'number') return (st as any).durationMillis ?? 0; + return 0; + } catch { + return 0; + } +}; + +export const AudioBubblePlayer = React.memo((props: AudioBubbleProps) => { + const { audioSrc, variant, id } = props; + + const dispatch = useDispatch(); + // Selects the key of the audio source that is currently globally playing. + const currentPlayingKey = useAppSelector(selectCurrentPlayingAudioSrc); + + // Local state for UI feedback. const [isSoundLoading, setIsSoundLoading] = useState(false); - const [isAudioPlaying, setAudioPlaying] = useState(false); + const [isAudioPlaying, setIsAudioPlaying] = useState(false); + // State to hold the audio source, potentially converted (e.g., OGG to WAV). const [convertedAudioSrc, setConvertedAudioSrc] = useState(audioSrc); - const dispatch = useDispatch(); - const currentPlayingAudioSrc = useAppSelector(selectCurrentPlayingAudioSrc); + // Local state for displaying current and total time. + const [currentTime, setCurrentTime] = useState('00:00'); + const [totalTime, setTotalTime] = useState('00:00'); + // Shared values for Reanimated slider to track playback progress. const currentPosition = useSharedValue(0); const totalDuration = useSharedValue(0); - const audioPlayBackStatus = useCallback( - (data: { data: PlayBackType }) => { - const playBackData = data.data as PlayBackType; - if (playBackData) { - currentPosition.value = playBackData.currentPosition; - totalDuration.value = playBackData.duration; - if (playBackData.currentPosition === playBackData.duration) { - currentPosition.value = 0; - totalDuration.value = 0; - setAudioPlaying(false); - dispatch(setCurrentPlayingAudioSrc('')); - } - } - }, - [currentPosition, totalDuration, dispatch], - ); + // Unique ref for this AudioBubblePlayer instance for PlayerHub registration. + const idRef = useRef(`ab_${id}`); + // Ref to track if the duration has been loaded to avoid repeated calculations. + const durationLoadedRef = useRef(false); + // Ref to store the last known AVPlaybackStatus, useful for resuming playback state. + const lastStatusRef = useRef(null); + // Guard to prevent `onEnded` from being called multiple times. + const endedGuardRef = useRef(false); + + // Memoized unique key for this audio source, used for global state management. + const key = useMemo(() => `${id}::${convertedAudioSrc}`, [id, convertedAudioSrc]); useEffect(() => { - const prepareAudio = async () => { - if (Platform.OS === 'ios' && audioSrc.toLowerCase().endsWith('.ogg')) { + /** + * Prepares the audio source. This includes converting OGG to WAV for iOS + * compatibility if necessary, and setting the `convertedAudioSrc`. + */ + const prepare = async () => { + setConvertedAudioSrc(audioSrc); // Start with the original audio source. + + const isOggLike = /\.(ogg|oga|opus)$/i.test(audioSrc); + if (Platform.OS === 'ios' && isOggLike) { setIsSoundLoading(true); try { - const convertedSrc = await convertOggToWav(audioSrc); - setConvertedAudioSrc(convertedSrc); - } catch (error) { - Sentry.captureException(error); + // Convert OGG to WAV for iOS compatibility. + const converted = await convertOggToWav(audioSrc); + const normalized = typeof (converted as any)?.uri === 'string' ? (converted as any).uri : converted; + setConvertedAudioSrc(typeof normalized === 'string' ? normalized : audioSrc); + } catch (e) { + Sentry.captureException(e); + setConvertedAudioSrc(audioSrc); // Fallback to original if conversion fails. } finally { setIsSoundLoading(false); } } }; - prepareAudio(); - }, [audioSrc]); - - const togglePlayback = useCallback(() => { - if (convertedAudioSrc === currentPlayingAudioSrc) { - if (isAudioPlaying) { - pausePlayer(); - } else { - resumePlayer(); + + prepare(); + }, [audioSrc]); // Re-run when the audio source changes. + + useEffect(() => { + // Reset playback state when the audio source or its key changes. + durationLoadedRef.current = false; + currentPosition.value = 0; + totalDuration.value = 0; + setIsAudioPlaying(false); + setCurrentTime('00:00'); + setTotalTime('00:00'); + + let cancelled = false; + // Probe the duration of the audio file to display total time on the slider. + probeDurationMs(convertedAudioSrc).then((d) => { + if (cancelled) return; + if (d > 0) { + durationLoadedRef.current = true; + totalDuration.value = d; + setTotalTime(formatTime(d)); + // Explicitly set currentPosition.value to 0 again after totalDuration is known + // to ensure the animated reaction in Slider picks up the change. + currentPosition.value = 0; } - setAudioPlaying(!isAudioPlaying); - } else { - setIsSoundLoading(true); - startPlayer(convertedAudioSrc, audioPlayBackStatus).then(() => { - setIsSoundLoading(false); - setAudioPlaying(true); - dispatch(setCurrentPlayingAudioSrc(convertedAudioSrc)); - }); + }); + + // If this audio is currently designated as the globally playing audio, + // ensure the Redux state reflects its key. + const hubKey = PlayerHub.getCurrentKey(); + if (hubKey && hubKey === key) { + dispatch(setCurrentPlayingAudioSrc(key)); } - }, [convertedAudioSrc, currentPlayingAudioSrc, isAudioPlaying, dispatch, audioPlayBackStatus]); - const manualSeekTo = useCallback(async (manualSeekPosition: number) => { - seekTo(manualSeekPosition).then(() => { - resumePlayer(); + return () => { + cancelled = true; // Cleanup for `probeDurationMs` to prevent state updates on unmount. + }; + }, [convertedAudioSrc, currentPosition, totalDuration]); // Dependencies for re-running this effect. + + /** + * Callback for PlayerHub to update the UI with the latest playback status. + * @param st The AVPlaybackStatus object from expo-av. + */ + const onStatus = useCallback( + (st: AVPlaybackStatus) => { + lastStatusRef.current = st; // Keep track of the last status. + + if (!st.isLoaded) { + setIsAudioPlaying(false); + return; + } + + // Extract playback position, duration, and playing status. + const pos = (st as any).positionMillis ?? 0; + const dur = (st as any).durationMillis ?? 0; + const playing = (st as any).isPlaying ?? false; + + currentPosition.value = pos; // Update Reanimated shared value for slider. + setCurrentTime(formatTime(pos)); // Update formatted current time for display. + setIsAudioPlaying(!!playing); // Update local playing state. + + if (dur > 0) { + // If duration is valid, update total duration if it hasn't been loaded or has changed. + if (!durationLoadedRef.current || totalDuration.value !== dur) { + durationLoadedRef.current = true; + totalDuration.value = dur; + setTotalTime(formatTime(dur)); + } + } + }, + [currentPosition, totalDuration], // Dependencies for this callback. + ); + + useEffect(() => { + // Register this AudioBubblePlayer instance with the global PlayerHub. + PlayerHub.register({ + id: idRef.current, // Unique ID for this player. + getSrc: () => convertedAudioSrc, // Function to get the current audio source. + getKey: () => key, // Function to get the unique key. + onLoading: setIsSoundLoading, // Callback to update loading state. + onStatus, // Callback for playback status updates. + onEnded: async () => { + // Callback when this audio track ends. + if (endedGuardRef.current) return; + endedGuardRef.current = true; + + setIsAudioPlaying(false); + dispatch(setCurrentPlayingAudioSrc('')); // Clear global playing state. + currentPosition.value = 0; + setCurrentTime('00:00'); + + // Enqueue playing the next track in the sequence. + await PlayerHub.enqueue(() => PlayerHub.playNextAfter(idRef.current)); + + // If a new track started playing, update the global playing state. + const nextKey = PlayerHub.getCurrentKey(); + if (nextKey) dispatch(setCurrentPlayingAudioSrc(nextKey)); + + endedGuardRef.current = false; + }, }); - }, []); - const pauseAudio = useCallback(async () => { - await pausePlayer(); - }, []); + return () => { + // Unregister from PlayerHub when the component unmounts. + // Note: PlayerHub.stopAndUnloadIfCurrent is not called here directly, + // as it's managed by PlayerHub's internal logic and HMR cleanup. + PlayerHub.unregister(idRef.current); + }; + }, [convertedAudioSrc, dispatch, currentPosition, key, onStatus]); - const isCurrentAudioSrcPlaying = useMemo( - () => currentPlayingAudioSrc === convertedAudioSrc && isAudioPlaying, - [convertedAudioSrc, currentPlayingAudioSrc, isAudioPlaying], + // Memoized boolean to check if this specific AudioBubblePlayer is the one currently playing globally. + const isThisCurrent = useMemo( + () => currentPlayingKey === key && PlayerHub.isCurrent(idRef.current), + [currentPlayingKey, key], ); useEffect(() => { - if (currentPlayingAudioSrc !== audioSrc) { + // If this player is no longer the globally active one, reset its local playback state. + if (!isThisCurrent) { + setIsAudioPlaying(false); currentPosition.value = 0; - totalDuration.value = 0; + setCurrentTime('00:00'); } - }, [currentPlayingAudioSrc, audioSrc, currentPosition, totalDuration]); + }, [isThisCurrent, currentPosition]); - useEffect(() => { - return () => { - stopPlayer() - .then() - .finally(() => { - setAudioPlaying(false); - dispatch(setCurrentPlayingAudioSrc('')); - }); - }; - }, [dispatch]); + /** + * Handles play/pause toggle for the audio bubble. + * Enqueues the operation to maintain sequential execution in PlayerHub. + */ + const togglePlayback = useCallback(async () => { + if (!convertedAudioSrc) return; // Cannot play if there's no source. + + await PlayerHub.enqueue(async () => { + try { + if (isThisCurrent) { + // If this player is currently active, toggle its play/pause state. + const st = lastStatusRef.current; + const playing = st && (st as any).isLoaded ? (st as any).isPlaying : isAudioPlaying; + + if (playing) { + PlayerHub.suppressNextOnce(idRef.current); // Prevent auto-play next if paused by user. + await PlayerHub.pause(); + } else { + await PlayerHub.resume(); + } + return; + } + + // If this player is not active, start playing its audio. + setIsSoundLoading(true); + await PlayerHub.playById(idRef.current); + setIsSoundLoading(false); + dispatch(setCurrentPlayingAudioSrc(key)); // Update global state. + } catch (e) { + setIsSoundLoading(false); + Sentry.captureException(e); + } + }); + }, [convertedAudioSrc, dispatch, isThisCurrent, isAudioPlaying, key]); + + /** + * Internal callback for seeking to a specific position (JavaScript thread). + * @param manualSeekPositionMs The position in milliseconds to seek to. + */ + const manualSeekToJS = useCallback( + async (manualSeekPositionMs: number) => { + await PlayerHub.enqueue(async () => { + const max = totalDuration.value > 0 ? totalDuration.value : manualSeekPositionMs; + const next = Math.max(0, Math.min(manualSeekPositionMs, max)); + + currentPosition.value = next; + setCurrentTime(formatTime(next)); + + try { + await PlayerHub.seekTo(next); + } catch (e) { + Sentry.captureException(e); + } + + // If this player is current and was paused, resume playback after seeking. + if (isThisCurrent) { + const st = lastStatusRef.current; + const playing = st && (st as any).isLoaded ? (st as any).isPlaying : isAudioPlaying; + if (!playing) { + try { + await PlayerHub.resume(); + } catch (e) { + Sentry.captureException(e); + } + } + } + }); + }, + [currentPosition, totalDuration, isThisCurrent, isAudioPlaying], + ); + + /** + * Internal callback for pausing audio (JavaScript thread). + */ + const pauseAudioJS = useCallback(async () => { + await PlayerHub.enqueue(async () => { + PlayerHub.suppressNextOnce(idRef.current); // Prevent auto-play next. + try { + await PlayerHub.pause(); + } catch (e) { + Sentry.captureException(e); + } + }); + }, []); + + // Memoized boolean indicating if this specific audio is currently playing and active. + const isCurrentAudioPlaying = useMemo( + () => isThisCurrent && isAudioPlaying, + [isThisCurrent, isAudioPlaying], + ); const sliderProps = useMemo( () => ({ trackColor: variant === MESSAGE_VARIANTS.USER ? 'bg-whiteA-A9' : 'bg-gray-500', filledTrackColor: variant === MESSAGE_VARIANTS.USER ? 'bg-white' : 'bg-blue-700', knobStyle: variant === MESSAGE_VARIANTS.USER ? 'border-blue-300' : 'border-blue-700', - manualSeekTo, + manualSeekTo: manualSeekToJS, // Pass plain JS function. currentPosition, totalDuration, - pauseAudio, + pauseAudio: pauseAudioJS, // Pass plain JS function. }), - [variant, manualSeekTo, currentPosition, totalDuration, pauseAudio], + [variant, manualSeekToJS, currentPosition, totalDuration, pauseAudioJS], // Update dependencies. ); return ( {isSoundLoading ? ( + // Show spinner when audio is loading - ) : isCurrentAudioSrcPlaying ? ( - - - } - size={13} + ) : isCurrentAudioPlaying ? ( + // Show pause icon if currently playing + + ) : ( - + // Show play icon if not playing + { )} + + + {/* Time display for current and total duration */} + + + {currentTime} + + + {totalTime} + + ); }); -// eslint-disable-next-line react/display-name -export const AudioBubble = React.memo(props => { - const { audioSrc, variant } = props; +export const AudioBubble = React.memo((props) => { + const { audioSrc, variant, id } = props; + // Renders the AudioBubblePlayer component, passing down relevant props. return ( - + ); }); diff --git a/src/screens/chat-screen/components/message-components/AudioCell.tsx b/src/screens/chat-screen/components/message-components/AudioCell.tsx index 638631b89..db901b89f 100644 --- a/src/screens/chat-screen/components/message-components/AudioCell.tsx +++ b/src/screens/chat-screen/components/message-components/AudioCell.tsx @@ -14,7 +14,7 @@ import { Channel, IconProps, Message, MessageStatus, UnixTimestamp } from '@/typ import { unixTimestampToReadableTime } from '@/utils'; import { Avatar, Icon, Slider } from '@/components-next/common'; import { Spinner } from '@/components-next/spinner'; -import { pausePlayer, resumePlayer, seekTo, startPlayer, stopPlayer } from '../audio-recorder'; +import { AudioStatus, pausePlayer, resumePlayer, seekTo, startPlayer, stopPlayer } from '../audio-recorder'; import { MenuOption, MessageMenu } from '../message-menu'; import { MESSAGE_TYPES } from '@/constants'; import { DeliveryStatus } from './DeliveryStatus'; @@ -39,6 +39,7 @@ export const PauseIcon = ({ fill, fillOpacity }: IconProps) => { }; type AudioCellProps = { + id: string; // Unique identifier for the message/audio. audioSrc: string; shouldRenderAvatar: boolean; messageType: number; @@ -52,13 +53,13 @@ type AudioCellProps = { errorMessage?: string; }; -type AudioPlayerProps = Pick & { +type AudioPlayerProps = Pick & { // Include 'id' here. isIncoming: boolean; isOutgoing: boolean; }; export const AudioPlayer = (props: AudioPlayerProps) => { - const { audioSrc, isIncoming } = props; + const { audioSrc, isIncoming, id } = props; // Destructure id here. const [isSoundLoading, setIsSoundLoading] = useState(false); const [isAudioPlaying, setAudioPlaying] = useState(false); @@ -69,8 +70,10 @@ export const AudioPlayer = (props: AudioPlayerProps) => { const currentPosition = useSharedValue(0); const totalDuration = useSharedValue(0); - const audioPlayBackStatus = (data: { data: PlayBackType }) => { - const playBackData = data.data as PlayBackType; + const audioKey = useMemo(() => `${id}::${audioSrc}`, [id, audioSrc]); // Construct the unique audio key. + + const audioPlayBackStatus = (args: { status: AudioStatus; data?: PlayBackType }) => { + const { data: playBackData, status } = args; if (playBackData) { currentPosition.value = playBackData.currentPosition; totalDuration.value = playBackData.duration; @@ -78,13 +81,13 @@ export const AudioPlayer = (props: AudioPlayerProps) => { currentPosition.value = 0; totalDuration.value = 0; setAudioPlaying(false); - dispatch(setCurrentPlayingAudioSrc('')); + dispatch(setCurrentPlayingAudioSrc('')); // Clear global state when playback finishes. } } }; const togglePlayback = () => { - if (audioSrc === currentPlayingAudioSrc) { + if (audioKey === currentPlayingAudioSrc) { // Compare using the unique audioKey. // The current playing audio file is same as the component audio src so // we will have to just toggle the audio playing if (isAudioPlaying) { @@ -99,7 +102,7 @@ export const AudioPlayer = (props: AudioPlayerProps) => { startPlayer(audioSrc, audioPlayBackStatus).then(() => { setIsSoundLoading(false); setAudioPlaying(true); - dispatch(setCurrentPlayingAudioSrc(audioSrc)); + dispatch(setCurrentPlayingAudioSrc(audioKey)); // Set global state with the unique audioKey. }); } }; @@ -115,17 +118,17 @@ export const AudioPlayer = (props: AudioPlayerProps) => { }; const isCurrentAudioSrcPlaying = useMemo( - () => currentPlayingAudioSrc === audioSrc && isAudioPlaying, - [audioSrc, currentPlayingAudioSrc, isAudioPlaying], + () => audioKey === currentPlayingAudioSrc && isAudioPlaying, // Compare using the unique audioKey. + [audioKey, currentPlayingAudioSrc, isAudioPlaying], ); useEffect(() => { - if (currentPlayingAudioSrc !== audioSrc) { + if (audioKey !== currentPlayingAudioSrc) { // Check against the unique audioKey. currentPosition.value = 0; totalDuration.value = 0; } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentPlayingAudioSrc]); + }, [currentPlayingAudioSrc, audioKey]); // Add audioKey to dependencies. useEffect(() => { return () => { @@ -133,11 +136,14 @@ export const AudioPlayer = (props: AudioPlayerProps) => { .then() .finally(() => { setAudioPlaying(false); - dispatch(setCurrentPlayingAudioSrc('')); + // Only clear global state if this audio is the one currently playing. + if (currentPlayingAudioSrc === audioKey) { + dispatch(setCurrentPlayingAudioSrc('')); + } }); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [audioKey, currentPlayingAudioSrc]); // Add audioKey and currentPlayingAudioSrc to dependencies. return ( @@ -213,9 +219,7 @@ export const AudioCell: React.FC = props => { )}> {sender?.name && isIncoming && shouldRenderAvatar ? ( - - - + ) : null} = props => { {sender?.name && isOutgoing && shouldRenderAvatar ? ( - + ) : null} diff --git a/src/screens/chat-screen/components/message-components/ComposedBubble.tsx b/src/screens/chat-screen/components/message-components/ComposedBubble.tsx index 929b65683..d0a07741d 100644 --- a/src/screens/chat-screen/components/message-components/ComposedBubble.tsx +++ b/src/screens/chat-screen/components/message-components/ComposedBubble.tsx @@ -8,7 +8,7 @@ import { Message } from '@/types'; import { Icon, Spinner } from '@/components-next'; import { ReplyMessageBubble } from './ReplyMessageBubble'; -import { ImageBubbleContainer } from './ImageBubble'; +import { ImageBubble } from './ImageBubble'; // Changed from ImageBubbleContainer import { useAppSelector } from '@/hooks'; import { useChatWindowContext } from '@/context'; @@ -20,6 +20,7 @@ import { FileBubblePreview } from './FileBubble'; import { AudioBubble } from './AudioBubble'; import { VideoBubble } from './VideoBubble'; import { LocationBubble } from './LocationBubble'; +// Removed: import {getRemoteImageSize} from '@/utils/messageUtils'; type ComposedBubbleProps = { item: Message; @@ -40,12 +41,14 @@ export const ComposedBubble = (props: ComposedBubbleProps) => { private: isPrivate, createdAt, contentAttributes, - status, + status: rawStatus, // Destructure status into a temporary variable } = props.item as Message; const { conversationId } = useChatWindowContext(); const messages = useAppSelector(state => getMessagesByConversationId(state, { conversationId })); + const messageStatus = rawStatus || MESSAGE_STATUS.SENT; // Provide a default status + const isReplyMessage = useMemo( () => contentAttributes?.inReplyTo !== undefined, [contentAttributes?.inReplyTo], @@ -61,8 +64,8 @@ export const ComposedBubble = (props: ComposedBubbleProps) => { const { imageType } = contentAttributes || {}; const isAnInstagramStory = imageType === ATTACHMENT_TYPES.STORY_MENTION; const isInstagramStoryExpired = isMessageCreatedAtLessThan24HoursOld(createdAt); - const isMessageSending = status === MESSAGE_STATUS.PROGRESS; - + const isMessageSending = messageStatus === MESSAGE_STATUS.PROGRESS; + // Removed: style={tailwind.style('my-2')} return ( {isPrivate ? ( @@ -81,6 +84,10 @@ export const ComposedBubble = (props: ComposedBubbleProps) => { {props.item.attachments && props.item.attachments.map((attachment, index) => { if (attachment.fileType === 'image') { + // Removed: getRemoteImageSize(attachment.dataUrl).then(({ width, height }) => { + // Removed: console.log('Image dimensions:', width, height); + // Removed: }) + return isAnInstagramStory && isInstagramStoryExpired ? ( { ) : ( - - + ); @@ -122,7 +128,7 @@ export const ComposedBubble = (props: ComposedBubbleProps) => { - + ); } @@ -140,7 +146,8 @@ export const ComposedBubble = (props: ComposedBubbleProps) => { - + {/* Cast attachment.id to String explicitly as AudioBubble expects a string ID. */} + ); } diff --git a/src/screens/chat-screen/components/message-components/ImageBubble.android.tsx b/src/screens/chat-screen/components/message-components/ImageBubble.android.tsx deleted file mode 100644 index ad944e030..000000000 --- a/src/screens/chat-screen/components/message-components/ImageBubble.android.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React from 'react'; -import Animated from 'react-native-reanimated'; -import { LightBox, LightBoxProps } from '@alantoa/lightbox'; -import { Image } from 'expo-image'; -import { tailwind } from '@/theme'; - -const AnimatedExpoImage = Animated.createAnimatedComponent(Image); - -type ImageCellProps = { - imageSrc: string; -}; - -type ImageContainerProps = Pick & - Pick; - -export const ImageBubbleContainer = (props: ImageContainerProps) => { - const { imageSrc, height: lightboxH, width: lightboxW } = props; - - return ( - - - - ); -}; - -export const ImageBubble = (props: ImageCellProps) => { - const { imageSrc } = props; - - return ( - - - - ); -}; diff --git a/src/screens/chat-screen/components/message-components/ImageBubble.ios.tsx b/src/screens/chat-screen/components/message-components/ImageBubble.ios.tsx deleted file mode 100644 index 83ddc7415..000000000 --- a/src/screens/chat-screen/components/message-components/ImageBubble.ios.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React from 'react'; -import { Image } from 'expo-image'; -import { Galeria } from '@nandorojo/galeria'; -import { tailwind } from '@/theme'; - -type ImageCellProps = { - imageSrc: string; -}; - -type ImageContainerProps = Pick & { - width?: number; - height?: number; -}; - -export const ImageBubbleContainer = (props: ImageContainerProps) => { - const { imageSrc, height = 215, width = 400 } = props; - - return ( - - - - - - ); -}; - -export const ImageBubble = (props: ImageCellProps) => { - const { imageSrc } = props; - - return ( - - - - ); -}; diff --git a/src/screens/chat-screen/components/message-components/ImageBubble.tsx b/src/screens/chat-screen/components/message-components/ImageBubble.tsx new file mode 100644 index 000000000..93fbe53be --- /dev/null +++ b/src/screens/chat-screen/components/message-components/ImageBubble.tsx @@ -0,0 +1,52 @@ +import React, { useState, useEffect } from 'react'; +import { Image as RNImage } from 'react-native'; +import { Image } from 'expo-image'; +import { Galeria } from '@nandorojo/galeria'; +import { tailwind } from '@/theme'; + +type ImageBubbleProps = { + imageSrc: string; // The source URI of the image to display. + width?: number; // Optional maximum width for the image bubble. +}; + +export const ImageBubble = (props: ImageBubbleProps) => { + const { imageSrc, width: imageWidth} = props; + // State to store the calculated width and height of the image to maintain aspect ratio. + // Defaults to a placeholder size before the actual dimensions are determined. + const [imageSize, setImageSize] = useState({ width: 300, height: 215 }); + + useEffect(() => { + // Get the actual dimensions of the remote image. + RNImage.getSize( + imageSrc, + (width, height) => { + const maxWidth = imageWidth || 300; // Use provided width or default to 300. + const aspectRatio = width / height; + const calculatedHeight = maxWidth / aspectRatio; // Calculate height to maintain aspect ratio. + + setImageSize({ width: maxWidth, height: calculatedHeight }); + }, + error => { + // Log an error if image dimensions cannot be retrieved. + console.error(`Couldn't get image size: ${error.message}`); + }, + ); + }, [imageSrc, imageWidth]); // Re-run effect if image source or desired width changes. + + return ( + // Galeria component provides image viewing capabilities, including fullscreen and pinch-to-zoom. + + + + + + ); +}; diff --git a/src/screens/chat-screen/components/message-components/ImageCell.tsx b/src/screens/chat-screen/components/message-components/ImageCell.tsx index 29272c565..6dd6994fe 100644 --- a/src/screens/chat-screen/components/message-components/ImageCell.tsx +++ b/src/screens/chat-screen/components/message-components/ImageCell.tsx @@ -55,7 +55,7 @@ export const ImageCell = (props: ImageCellProps) => { {sender?.name && isIncoming && shouldRenderAvatar ? ( - + ) : null} @@ -123,7 +123,7 @@ export const ImageCell = (props: ImageCellProps) => { {sender?.name && isOutgoing && shouldRenderAvatar ? ( - + ) : null} diff --git a/src/screens/chat-screen/components/message-components/VideoBubble.tsx b/src/screens/chat-screen/components/message-components/VideoBubble.tsx index edff97882..c3c62e8c6 100644 --- a/src/screens/chat-screen/components/message-components/VideoBubble.tsx +++ b/src/screens/chat-screen/components/message-components/VideoBubble.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Platform, Pressable } from 'react-native'; import Animated, { Easing, FadeIn, FadeOut } from 'react-native-reanimated'; import { @@ -13,86 +13,111 @@ import { tailwind } from '@/theme'; import { Spinner } from '@/components-next/spinner'; type VideoBubbleProps = { - videoSrc: string; + videoSrc: string; // The source URI of the video to display. + width?: number; // Optional maximum width for the video bubble. }; -type VideoPlayerProps = Pick & { - playerEnabled?: boolean; -}; +type VideoPlayerProps = Pick; // Inherit videoSrc and width from VideoBubbleProps. export const VideoBubblePlayer = (props: VideoPlayerProps) => { - const { videoSrc, playerEnabled = true } = props; - const video = React.useRef