From 1a43d5b3daacdec3777648de4c031a3018841e06 Mon Sep 17 00:00:00 2001 From: Vivek Arya Date: Wed, 1 Jul 2026 10:59:41 +0530 Subject: [PATCH 1/2] feat: Add Listen Together synchronized podcast feature --- .../src/components/ListenTogetherChat.tsx | 270 ++++++++ .../src/components/ListenTogetherRoomBar.tsx | 237 +++++++ frontend/src/hooks/useListenTogether.ts | 317 +++++++++ frontend/src/navigations/StackNavigation.tsx | 13 + .../src/screens/JoinListenTogetherScreen.tsx | 270 ++++++++ frontend/src/screens/ListenTogetherScreen.tsx | 632 ++++++++++++++++++ frontend/src/screens/PodcastDetail.tsx | 30 + frontend/src/type.ts | 19 + frontend/src/types/ListenTogetherTypes.ts | 103 +++ 9 files changed, 1891 insertions(+) create mode 100644 frontend/src/components/ListenTogetherChat.tsx create mode 100644 frontend/src/components/ListenTogetherRoomBar.tsx create mode 100644 frontend/src/hooks/useListenTogether.ts create mode 100644 frontend/src/screens/JoinListenTogetherScreen.tsx create mode 100644 frontend/src/screens/ListenTogetherScreen.tsx create mode 100644 frontend/src/types/ListenTogetherTypes.ts diff --git a/frontend/src/components/ListenTogetherChat.tsx b/frontend/src/components/ListenTogetherChat.tsx new file mode 100644 index 000000000..de7ee7899 --- /dev/null +++ b/frontend/src/components/ListenTogetherChat.tsx @@ -0,0 +1,270 @@ +/** + * ListenTogetherChat — Live chat overlay for Listen Together sessions. + * + * Renders a scrollable list of chat messages with a text input bar. + * Messages show the sender's initial, handle, text, and timestamp. + * Auto-scrolls to the newest message on arrival. + */ + +import React, {useCallback, useRef, useState} from 'react'; +import { + FlatList, + KeyboardAvoidingView, + Platform, + StyleSheet, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; +import {Text, XStack, YStack} from 'tamagui'; +import {Ionicons} from '@expo/vector-icons'; +import {ListenTogetherMessage} from '../types/ListenTogetherTypes'; + +interface ListenTogetherChatProps { + messages: ListenTogetherMessage[]; + currentUserId: string; + onSendMessage: (text: string) => void; +} + +const ListenTogetherChat: React.FC = ({ + messages, + currentUserId, + onSendMessage, +}) => { + const [inputText, setInputText] = useState(''); + const flatListRef = useRef(null); + + const handleSend = useCallback(() => { + if (!inputText.trim()) return; + onSendMessage(inputText.trim()); + setInputText(''); + }, [inputText, onSendMessage]); + + const formatTime = (isoString: string) => { + try { + const date = new Date(isoString); + const hours = date.getHours().toString().padStart(2, '0'); + const minutes = date.getMinutes().toString().padStart(2, '0'); + return `${hours}:${minutes}`; + } catch { + return ''; + } + }; + + const renderMessage = useCallback( + ({item}: {item: ListenTogetherMessage}) => { + const isOwnMessage = item.userId === currentUserId; + + return ( + + {/* Avatar + handle (for other people's messages) */} + {!isOwnMessage && ( + + + + {item.userHandle?.charAt(0)?.toUpperCase() || '?'} + + + + @{item.userHandle} + + + )} + + + {item.text} + + + + {formatTime(item.sentAt)} + + + ); + }, + [currentUserId], + ); + + const keyExtractor = useCallback( + (item: ListenTogetherMessage) => item.id, + [], + ); + + return ( + + {/* Header */} + + + + LIVE CHAT + + + + + + + {/* Messages */} + { + flatListRef.current?.scrollToEnd({animated: true}); + }} + ListEmptyComponent={ + + + + No messages yet.{'\n'}Start the conversation! + + + } + /> + + {/* Input bar */} + + + + + + + + ); +}; + +export default ListenTogetherChat; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#0F172A', + borderRadius: 16, + borderWidth: 1, + borderColor: '#1E293B', + overflow: 'hidden', + }, + liveIndicator: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: '#EF444440', + alignItems: 'center', + justifyContent: 'center', + }, + liveDot: { + width: 5, + height: 5, + borderRadius: 2.5, + backgroundColor: '#EF4444', + }, + messagesList: { + paddingHorizontal: 12, + paddingBottom: 8, + flexGrow: 1, + }, + messageBubble: { + maxWidth: '85%', + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 14, + marginVertical: 3, + }, + ownMessage: { + backgroundColor: '#1E3A5F', + alignSelf: 'flex-end', + borderBottomRightRadius: 4, + }, + otherMessage: { + backgroundColor: '#1E293B', + alignSelf: 'flex-start', + borderBottomLeftRadius: 4, + }, + messageAvatar: { + width: 20, + height: 20, + borderRadius: 10, + backgroundColor: '#334155', + alignItems: 'center', + justifyContent: 'center', + }, + inputContainer: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 10, + paddingVertical: 8, + borderTopWidth: 1, + borderTopColor: '#1E293B', + gap: 8, + }, + textInput: { + flex: 1, + backgroundColor: '#1E293B', + borderRadius: 20, + paddingHorizontal: 16, + paddingVertical: 10, + color: '#F1F5F9', + fontSize: 14, + maxHeight: 80, + }, + sendButton: { + width: 38, + height: 38, + borderRadius: 19, + backgroundColor: '#3B82F6', + alignItems: 'center', + justifyContent: 'center', + }, + sendButtonDisabled: { + backgroundColor: '#334155', + }, +}); diff --git a/frontend/src/components/ListenTogetherRoomBar.tsx b/frontend/src/components/ListenTogetherRoomBar.tsx new file mode 100644 index 000000000..6f8deb1b5 --- /dev/null +++ b/frontend/src/components/ListenTogetherRoomBar.tsx @@ -0,0 +1,237 @@ +/** + * ListenTogetherRoomBar — Top bar for a Listen Together session. + * + * Shows the room code (tap to copy), participant avatars, sync status, + * a share button, and a leave/end button. + */ + +import React, {useCallback} from 'react'; +import { + Alert, + StyleSheet, + TouchableOpacity, + View, +} from 'react-native'; +import {XStack, YStack, Text} from 'tamagui'; +import {Ionicons, Feather, MaterialIcons} from '@expo/vector-icons'; +import * as Clipboard from 'expo-linking'; // We use Alert-based copy fallback +import Share from 'react-native-share'; +import Snackbar from 'react-native-snackbar'; +import {ListenTogetherParticipant} from '../types/ListenTogetherTypes'; +import {SHARE_BASE_URL} from '../helper/APIUtils'; + +interface ListenTogetherRoomBarProps { + roomCode: string; + participants: ListenTogetherParticipant[]; + isHost: boolean; + isSyncing: boolean; + onLeave: () => void; + onEnd: () => void; +} + +const ListenTogetherRoomBar: React.FC = ({ + roomCode, + participants, + isHost, + isSyncing, + onLeave, + onEnd, +}) => { + const handleCopyCode = useCallback(() => { + // React Native doesn't have a direct Clipboard in Expo without the module, + // so we show a Snackbar with the code for now. + Snackbar.show({ + text: `Room code copied: ${roomCode}`, + duration: Snackbar.LENGTH_SHORT, + }); + }, [roomCode]); + + const handleShare = useCallback(async () => { + try { + const url = `${SHARE_BASE_URL}/listen-together?code=${roomCode}`; + await Share.open({ + title: 'Listen Together', + message: `Join my Listen Together session on UltimateHealth! Room code: ${roomCode}`, + url, + subject: 'Listen Together Invite', + }); + } catch { + // User cancelled share sheet — no action needed + } + }, [roomCode]); + + const handleLeaveOrEnd = useCallback(() => { + const title = isHost ? 'End Session' : 'Leave Session'; + const message = isHost + ? 'Ending the session will disconnect all listeners. Are you sure?' + : 'Are you sure you want to leave this listening session?'; + + Alert.alert(title, message, [ + {text: 'Cancel', style: 'cancel'}, + { + text: isHost ? 'End' : 'Leave', + style: 'destructive', + onPress: isHost ? onEnd : onLeave, + }, + ]); + }, [isHost, onEnd, onLeave]); + + // Max 3 avatars shown, rest as "+N" + const displayParticipants = participants.slice(0, 3); + const extraCount = Math.max(0, participants.length - 3); + + return ( + + {/* Left: Room code */} + + + + {roomCode} + + + + + {/* Center: Participants + sync status */} + + {/* Participant avatars (stacked) */} + + {displayParticipants.map((p, index) => ( + 0 ? -10 : 0, zIndex: 10 - index}, + ]}> + + {p.userHandle?.charAt(0)?.toUpperCase() || '?'} + + + ))} + {extraCount > 0 && ( + + + +{extraCount} + + + )} + + + {/* Sync status pill */} + + + + {isSyncing ? 'Syncing' : 'In Sync'} + + + + + {/* Right: Share + Leave/End */} + + + + + + + + + + + ); +}; + +export default ListenTogetherRoomBar; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + backgroundColor: '#1E293B', + borderRadius: 16, + paddingHorizontal: 14, + paddingVertical: 10, + borderWidth: 1, + borderColor: '#334155', + }, + codeContainer: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#0F172A', + borderRadius: 10, + paddingHorizontal: 10, + paddingVertical: 6, + }, + copyIcon: { + marginLeft: 6, + }, + avatarCircle: { + width: 28, + height: 28, + borderRadius: 14, + backgroundColor: '#334155', + alignItems: 'center', + justifyContent: 'center', + borderWidth: 2, + borderColor: '#1E293B', + }, + syncPill: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 20, + gap: 4, + }, + syncDot: { + width: 6, + height: 6, + borderRadius: 3, + }, + iconButton: { + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: '#0F172A', + alignItems: 'center', + justifyContent: 'center', + }, + leaveButton: { + backgroundColor: '#EF444420', + }, +}); diff --git a/frontend/src/hooks/useListenTogether.ts b/frontend/src/hooks/useListenTogether.ts new file mode 100644 index 000000000..61a6e9494 --- /dev/null +++ b/frontend/src/hooks/useListenTogether.ts @@ -0,0 +1,317 @@ +/** + * useListenTogether — Core hook for the synchronized podcast listening feature. + * + * Manages room creation/joining, playback synchronisation via Socket.IO, + * and live chat messaging. The hook is designed so that only the **host** + * (room creator) can issue playback commands; listeners receive sync updates + * and apply them to their local AudioPlayer instance. + */ + +import {useCallback, useEffect, useRef, useState} from 'react'; +import {useSocket} from '../contexts/SocketContext'; +import {useSelector} from 'react-redux'; +import { + LISTEN_TOGETHER_EVENTS, + ListenTogetherMessage, + ListenTogetherParticipant, + ListenTogetherRoom, + ListenTogetherSyncPayload, + RoomCreatedPayload, + RoomJoinedPayload, + ListenTogetherError, + SyncAction, +} from '../types/ListenTogetherTypes'; + +// --------------------------------------------------------------------------- +// Hook return type +// --------------------------------------------------------------------------- + +export interface UseListenTogetherReturn { + // State + room: ListenTogetherRoom | null; + roomCode: string | null; + participants: ListenTogetherParticipant[]; + messages: ListenTogetherMessage[]; + isHost: boolean; + isInRoom: boolean; + isSyncing: boolean; + error: string | null; + + // Actions + createRoom: (podcastId: string, audioUrl: string, podcastTitle: string, podcastCoverImage: string) => void; + joinRoom: (roomCode: string) => void; + leaveRoom: () => void; + endRoom: () => void; + syncPlayback: (action: SyncAction, position: number) => void; + sendMessage: (text: string) => void; + clearError: () => void; + + // Incoming sync (listeners apply this to their player) + lastSyncPayload: ListenTogetherSyncPayload | null; +} + +// --------------------------------------------------------------------------- +// Hook implementation +// --------------------------------------------------------------------------- + +export const useListenTogether = (): UseListenTogetherReturn => { + const socket = useSocket(); + const {user_id, user_handle} = useSelector((state: any) => state.user); + const profileImage = useSelector((state: any) => state.user.profile_image ?? null); + + // --- State --------------------------------------------------------------- + const [room, setRoom] = useState(null); + const [roomCode, setRoomCode] = useState(null); + const [participants, setParticipants] = useState([]); + const [messages, setMessages] = useState([]); + const [isHost, setIsHost] = useState(false); + const [isSyncing, setIsSyncing] = useState(false); + const [error, setError] = useState(null); + const [lastSyncPayload, setLastSyncPayload] = useState(null); + + // Keep a ref to roomCode so event callbacks always see the latest value. + const roomCodeRef = useRef(null); + roomCodeRef.current = roomCode; + + const isInRoom = room !== null; + + // --- Helpers ------------------------------------------------------------- + + const clearError = useCallback(() => setError(null), []); + + const generateMessageId = () => + `msg_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`; + + // --- Actions (emitters) -------------------------------------------------- + + const createRoom = useCallback( + (podcastId: string, audioUrl: string, podcastTitle: string, podcastCoverImage: string) => { + if (!socket) { + setError('Socket not connected'); + return; + } + socket.emit(LISTEN_TOGETHER_EVENTS.CREATE_ROOM, { + podcastId, + audioUrl, + podcastTitle, + podcastCoverImage, + userId: user_id, + userHandle: user_handle, + profileImage, + }); + }, + [socket, user_id, user_handle, profileImage], + ); + + const joinRoom = useCallback( + (code: string) => { + if (!socket) { + setError('Socket not connected'); + return; + } + setIsSyncing(true); + socket.emit(LISTEN_TOGETHER_EVENTS.JOIN_ROOM, { + roomCode: code.toUpperCase(), + userId: user_id, + userHandle: user_handle, + profileImage, + }); + }, + [socket, user_id, user_handle, profileImage], + ); + + const leaveRoom = useCallback(() => { + if (!socket || !roomCodeRef.current) return; + socket.emit(LISTEN_TOGETHER_EVENTS.LEAVE_ROOM, { + roomCode: roomCodeRef.current, + userId: user_id, + }); + // Optimistic local cleanup + setRoom(null); + setRoomCode(null); + setParticipants([]); + setMessages([]); + setIsHost(false); + setLastSyncPayload(null); + }, [socket, user_id]); + + const endRoom = useCallback(() => { + if (!socket || !roomCodeRef.current) return; + socket.emit(LISTEN_TOGETHER_EVENTS.END_ROOM, { + roomCode: roomCodeRef.current, + userId: user_id, + }); + // Optimistic local cleanup + setRoom(null); + setRoomCode(null); + setParticipants([]); + setMessages([]); + setIsHost(false); + setLastSyncPayload(null); + }, [socket, user_id]); + + const syncPlayback = useCallback( + (action: SyncAction, position: number) => { + if (!socket || !roomCodeRef.current) return; + const payload: ListenTogetherSyncPayload = { + roomCode: roomCodeRef.current, + action, + position, + timestamp: Date.now(), + }; + socket.emit(LISTEN_TOGETHER_EVENTS.SYNC_PLAYBACK, payload); + }, + [socket], + ); + + const sendMessage = useCallback( + (text: string) => { + if (!socket || !roomCodeRef.current || !text.trim()) return; + const message: ListenTogetherMessage = { + id: generateMessageId(), + roomCode: roomCodeRef.current, + userId: user_id, + userHandle: user_handle, + profileImage, + text: text.trim(), + sentAt: new Date().toISOString(), + }; + socket.emit(LISTEN_TOGETHER_EVENTS.SEND_CHAT, message); + // Optimistic: add locally immediately + setMessages(prev => [...prev, message]); + }, + [socket, user_id, user_handle, profileImage], + ); + + // --- Listeners (incoming events) ----------------------------------------- + + useEffect(() => { + if (!socket) return; + + // Room created (host receives this) + const onRoomCreated = (payload: RoomCreatedPayload) => { + setRoom(payload.room); + setRoomCode(payload.roomCode); + setParticipants(payload.room.participants); + setIsHost(true); + setIsSyncing(false); + setError(null); + }; + + // Room joined (listener receives this) + const onRoomJoined = (payload: RoomJoinedPayload) => { + setRoom(payload.room); + setRoomCode(payload.room.roomCode); + setParticipants(payload.room.participants); + setIsHost(false); + setIsSyncing(false); + setError(null); + + // Create an initial sync payload so the listener can seek to the correct position + setLastSyncPayload({ + roomCode: payload.room.roomCode, + action: payload.isPlaying ? 'play' : 'pause', + position: payload.currentPosition, + timestamp: Date.now(), + }); + }; + + // A new participant joined + const onParticipantJoined = (participant: ListenTogetherParticipant) => { + setParticipants(prev => { + if (prev.some(p => p.userId === participant.userId)) return prev; + return [...prev, participant]; + }); + }; + + // A participant left + const onParticipantLeft = (data: {userId: string}) => { + setParticipants(prev => prev.filter(p => p.userId !== data.userId)); + }; + + // Room ended by host + const onRoomEnded = () => { + setRoom(null); + setRoomCode(null); + setParticipants([]); + setMessages([]); + setIsHost(false); + setLastSyncPayload(null); + }; + + // Sync update from host + const onSyncUpdate = (payload: ListenTogetherSyncPayload) => { + setIsSyncing(true); + setLastSyncPayload(payload); + // Mark synced after a short delay (UI indicator) + setTimeout(() => setIsSyncing(false), 500); + }; + + // Incoming chat message + const onChatMessage = (message: ListenTogetherMessage) => { + setMessages(prev => { + // Deduplicate (in case our own optimistic message already exists) + if (prev.some(m => m.id === message.id)) return prev; + return [...prev, message]; + }); + }; + + // Error + const onError = (err: ListenTogetherError) => { + setError(err.message); + setIsSyncing(false); + }; + + socket.on(LISTEN_TOGETHER_EVENTS.ROOM_CREATED, onRoomCreated); + socket.on(LISTEN_TOGETHER_EVENTS.ROOM_JOINED, onRoomJoined); + socket.on(LISTEN_TOGETHER_EVENTS.PARTICIPANT_JOINED, onParticipantJoined); + socket.on(LISTEN_TOGETHER_EVENTS.PARTICIPANT_LEFT, onParticipantLeft); + socket.on(LISTEN_TOGETHER_EVENTS.ROOM_ENDED, onRoomEnded); + socket.on(LISTEN_TOGETHER_EVENTS.SYNC_UPDATE, onSyncUpdate); + socket.on(LISTEN_TOGETHER_EVENTS.CHAT_MESSAGE, onChatMessage); + socket.on(LISTEN_TOGETHER_EVENTS.ERROR, onError); + + return () => { + socket.off(LISTEN_TOGETHER_EVENTS.ROOM_CREATED, onRoomCreated); + socket.off(LISTEN_TOGETHER_EVENTS.ROOM_JOINED, onRoomJoined); + socket.off(LISTEN_TOGETHER_EVENTS.PARTICIPANT_JOINED, onParticipantJoined); + socket.off(LISTEN_TOGETHER_EVENTS.PARTICIPANT_LEFT, onParticipantLeft); + socket.off(LISTEN_TOGETHER_EVENTS.ROOM_ENDED, onRoomEnded); + socket.off(LISTEN_TOGETHER_EVENTS.SYNC_UPDATE, onSyncUpdate); + socket.off(LISTEN_TOGETHER_EVENTS.CHAT_MESSAGE, onChatMessage); + socket.off(LISTEN_TOGETHER_EVENTS.ERROR, onError); + }; + }, [socket]); + + // --- Cleanup on unmount (leave room automatically) ----------------------- + + useEffect(() => { + return () => { + if (roomCodeRef.current && socket) { + socket.emit(LISTEN_TOGETHER_EVENTS.LEAVE_ROOM, { + roomCode: roomCodeRef.current, + userId: user_id, + }); + } + }; + }, [socket, user_id]); + + return { + room, + roomCode, + participants, + messages, + isHost, + isInRoom, + isSyncing, + error, + createRoom, + joinRoom, + leaveRoom, + endRoom, + syncPlayback, + sendMessage, + clearError, + lastSyncPayload, + }; +}; diff --git a/frontend/src/navigations/StackNavigation.tsx b/frontend/src/navigations/StackNavigation.tsx index f1a05b105..451dd2e23 100644 --- a/frontend/src/navigations/StackNavigation.tsx +++ b/frontend/src/navigations/StackNavigation.tsx @@ -52,6 +52,8 @@ import ContributorPage from '../screens/ContributorPage'; import OpenSourcePage from '../screens/OpenSourcePage'; import NotificationPreferencesScreen from '../screens/NotificationPreferencesScreen'; import GuestPlaceholderScreen from '../components/GuestPlaceholderScreen'; +import ListenTogetherScreen from '../screens/ListenTogetherScreen'; +import JoinListenTogetherScreen from '../screens/JoinListenTogetherScreen'; const Stack = createStackNavigator(); @@ -853,6 +855,17 @@ const StackNavigation = () => { headerBackTitleVisible: false, })} /> + + + {/* { + const prefillCode = route.params?.roomCode || ''; + const [code, setCode] = useState(prefillCode.toUpperCase()); + const [isJoining, setIsJoining] = useState(false); + const inputRef = useRef(null); + + // Auto-focus input on mount + useEffect(() => { + const timer = setTimeout(() => inputRef.current?.focus(), 300); + return () => clearTimeout(timer); + }, []); + + const handleCodeChange = useCallback((text: string) => { + // Only allow alphanumeric, max 6 chars + const sanitized = text.replace(/[^A-Za-z0-9]/g, '').toUpperCase().slice(0, CODE_LENGTH); + setCode(sanitized); + }, []); + + const handleJoin = useCallback(() => { + if (code.length !== CODE_LENGTH) return; + + Keyboard.dismiss(); + setIsJoining(true); + + // Navigate to ListenTogetherScreen in listener mode + // The actual socket join happens inside that screen via the hook + navigation.replace('ListenTogetherScreen', { + trackId: '', // Will be populated from the room data received via socket + audioUrl: null, + roomCode: code, + mode: 'listener', + }); + }, [code, navigation]); + + const isCodeValid = code.length === CODE_LENGTH; + + return ( + + + {/* Back button */} + navigation.goBack()} + accessibilityLabel="Go back"> + + + + {/* Icon */} + + + + + {/* Title */} + + Join Listening Session + + + Enter the 6-character room code{'\n'}shared by your friend. + + + {/* Code Input */} + + + {Array.from({length: CODE_LENGTH}).map((_, i) => { + const char = code[i] || ''; + const isFocused = code.length === i; + + return ( + + + {char} + + + ); + })} + + + {/* Hidden TextInput for keyboard capture */} + + + {/* Tap overlay to re-focus */} + inputRef.current?.focus()} + /> + + + {/* Join Button */} + + {isJoining ? ( + + ) : ( + + + + Join Session + + + )} + + + {/* Hint */} + + + + Ask the host for the room code + + + + + ); +}; + +export default JoinListenTogetherScreen; + +// --------------------------------------------------------------------------- +// Styles +// --------------------------------------------------------------------------- + +const styles = StyleSheet.create({ + backButton: { + position: 'absolute', + top: 50, + left: 20, + width: 42, + height: 42, + borderRadius: 21, + backgroundColor: '#1E293B', + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, + borderColor: '#334155', + zIndex: 10, + }, + iconContainer: { + width: 100, + height: 100, + borderRadius: 50, + backgroundColor: '#1E293B', + alignItems: 'center', + justifyContent: 'center', + borderWidth: 2, + borderColor: '#3B82F6', + shadowColor: '#3B82F6', + shadowOffset: {width: 0, height: 8}, + shadowRadius: 24, + shadowOpacity: 0.3, + elevation: 8, + }, + codeInputWrapper: { + marginTop: 40, + marginBottom: 32, + position: 'relative', + }, + codeBox: { + width: 48, + height: 60, + borderRadius: 12, + backgroundColor: '#1E293B', + borderWidth: 2, + borderColor: '#334155', + alignItems: 'center', + justifyContent: 'center', + }, + codeBoxFilled: { + borderColor: '#3B82F6', + backgroundColor: '#1E3A5F', + }, + codeBoxFocused: { + borderColor: '#60A5FA', + shadowColor: '#3B82F6', + shadowOffset: {width: 0, height: 0}, + shadowRadius: 8, + shadowOpacity: 0.4, + }, + hiddenInput: { + position: 'absolute', + width: 1, + height: 1, + opacity: 0, + }, + joinButton: { + backgroundColor: '#3B82F6', + paddingHorizontal: 40, + paddingVertical: 16, + borderRadius: 16, + shadowColor: '#3B82F6', + shadowOffset: {width: 0, height: 8}, + shadowRadius: 20, + shadowOpacity: 0.4, + elevation: 8, + minWidth: 200, + alignItems: 'center', + }, + joinButtonDisabled: { + backgroundColor: '#334155', + shadowOpacity: 0, + elevation: 0, + }, +}); diff --git a/frontend/src/screens/ListenTogetherScreen.tsx b/frontend/src/screens/ListenTogetherScreen.tsx new file mode 100644 index 000000000..a5e097940 --- /dev/null +++ b/frontend/src/screens/ListenTogetherScreen.tsx @@ -0,0 +1,632 @@ +/** + * ListenTogetherScreen — Main screen for synchronized podcast listening. + * + * Hosts create a room; listeners join via a room code. Both see a shared + * podcast player (host controls) and a live chat overlay. + */ + +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import { + Alert, + Image, + StyleSheet, + TouchableOpacity, + View, +} from 'react-native'; +import {Theme, XStack, YStack, Text, ScrollView} from 'tamagui'; +import {Ionicons} from '@expo/vector-icons'; +import Slider from '@react-native-community/slider'; +import {useAudioPlayer} from 'expo-audio'; +import {useSelector} from 'react-redux'; +import Snackbar from 'react-native-snackbar'; + +import {ListenTogetherScreenProp} from '../type'; +import {GET_IMAGE} from '../helper/APIUtils'; +import {useListenTogether} from '../hooks/useListenTogether'; +import {useGetSinglePodcastDetails} from '../hooks/useGetSinglePodcastDetails'; +import ListenTogetherRoomBar from '../components/ListenTogetherRoomBar'; +import ListenTogetherChat from '../components/ListenTogetherChat'; +import AudioWaveform from '../components/AudioWaveform'; +import Loader from '../components/Loader'; +import {GlassStyles} from '../styles/GlassStyles'; +import {SyncAction} from '../types/ListenTogetherTypes'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const isAllowedUrl = (urlStr?: string | null): boolean => { + if (!urlStr) return false; + if (!urlStr.startsWith('http://') && !urlStr.startsWith('https://')) { + if (urlStr.includes('://')) return false; + return true; + } + try { + const match = urlStr.match(/^https?:\/\/([^/?#:]+)/i); + if (!match) return false; + const hostname = match[1].toLowerCase(); + return ( + hostname === 'uhsocial.in' || + hostname === 'localhost' || + hostname === '10.0.2.2' + ); + } catch { + return false; + } +}; + +const getFormattedSource = (url?: string | null): string | null => { + if (!url) return null; + if (!isAllowedUrl(url)) return null; + return url.startsWith('http') ? url : `${GET_IMAGE}/${url}`; +}; + +const formatSecTime = (seconds: number) => { + const hours = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + if (hours > 0) { + return `${hours}:${mins < 10 ? '0' : ''}${mins}:${secs < 10 ? '0' : ''}${secs}`; + } + return `${mins}:${secs < 10 ? '0' : ''}${secs}`; +}; + +const SKIP_TIME = 5; + +// --------------------------------------------------------------------------- +// Screen +// --------------------------------------------------------------------------- + +const ListenTogetherScreen = ({navigation, route}: ListenTogetherScreenProp) => { + const {trackId, audioUrl, roomCode: initialRoomCode, mode} = route.params; + const {user_id} = useSelector((state: any) => state.user); + const {isConnected} = useSelector((state: any) => state.network); + + // Podcast details + const {data: podcast, isLoading: isPodcastLoading} = + useGetSinglePodcastDetails(trackId); + + // Audio player + const defaultFallback = require('../../assets/sounds/funny-cartoon-sound-397415.mp3'); + const initialSource = getFormattedSource(audioUrl) ?? defaultFallback; + const player = useAudioPlayer(initialSource); + + // Playback state + const [position, setPosition] = useState(0); + const [duration, setDuration] = useState(0); + const [isPlaying, setIsPlaying] = useState(false); + + // Listen Together hook + const { + room, + roomCode, + participants, + messages, + isHost, + isInRoom, + isSyncing, + error, + createRoom, + joinRoom, + leaveRoom, + endRoom, + syncPlayback, + sendMessage, + clearError, + lastSyncPayload, + } = useListenTogether(); + + // Track if we've already initiated room creation/joining + const initiatedRef = useRef(false); + + // --- Room creation / joining on mount --- + useEffect(() => { + if (initiatedRef.current) return; + initiatedRef.current = true; + + if (mode === 'host') { + const title = podcast?.title || 'Health Podcast'; + const cover = podcast?.cover_image || ''; + const audio = audioUrl || podcast?.audio_url || ''; + createRoom(trackId, audio, title, cover); + } else if (mode === 'listener' && initialRoomCode) { + joinRoom(initialRoomCode); + } + }, [mode, trackId, audioUrl, initialRoomCode, podcast, createRoom, joinRoom]); + + // --- Replace audio source when podcast data loads --- + useEffect(() => { + if (podcast?.audio_url) { + const secureSource = getFormattedSource(podcast.audio_url); + if (secureSource) { + try { + player.replace(secureSource); + } catch (err) { + console.warn('Failed to replace player source:', err); + } + } + } + }, [podcast?.audio_url, player]); + + // --- Position / duration polling --- + useEffect(() => { + if (!player) return; + const interval = setInterval(() => { + const status = player.currentStatus; + if (status) { + setPosition(status.currentTime || 0); + setDuration(player.duration || status.duration || 0); + setIsPlaying(status.playing || false); + } + }, 500); + return () => clearInterval(interval); + }, [player]); + + // --- Apply incoming sync from host (listeners only) --- + useEffect(() => { + if (!lastSyncPayload || isHost || !player) return; + + const applySync = async () => { + try { + const {action, position: syncPos} = lastSyncPayload; + + // Compensate for network latency: adjust position by the time + // elapsed since the host sent the sync event. + const latencyMs = Math.max(0, Date.now() - lastSyncPayload.timestamp); + const adjustedPos = + action === 'play' ? syncPos + latencyMs / 1000 : syncPos; + + await player.seekTo(adjustedPos); + setPosition(adjustedPos); + + if (action === 'play') { + player.play(); + setIsPlaying(true); + } else if (action === 'pause' || action === 'seek') { + player.pause(); + setIsPlaying(false); + } else if (action === 'ended') { + player.pause(); + await player.seekTo(0); + setIsPlaying(false); + setPosition(0); + } + } catch (err) { + console.warn('Failed to apply sync:', err); + } + }; + + void applySync(); + }, [lastSyncPayload, isHost, player]); + + // --- Error display --- + useEffect(() => { + if (error) { + Snackbar.show({ + text: error, + duration: Snackbar.LENGTH_LONG, + }); + clearError(); + } + }, [error, clearError]); + + // --- Playback controls (host only) --- + const handlePlay = useCallback(async () => { + if (!player || !isHost) return; + try { + // Restart from beginning if the track has fully finished. + if (duration > 0 && position >= duration - 0.5) { + await player.seekTo(0); + setPosition(0); + } + player.play(); + setIsPlaying(true); + syncPlayback('play', player.currentTime || 0); + } catch (err) { + console.warn('Play error:', err); + } + }, [player, isHost, duration, position, syncPlayback]); + + const handlePause = useCallback(async () => { + if (!player || !isHost) return; + try { + player.pause(); + setIsPlaying(false); + syncPlayback('pause', player.currentTime || 0); + } catch (err) { + console.warn('Pause error:', err); + } + }, [player, isHost, syncPlayback]); + + const handleForward = useCallback(async () => { + if (!player || !isHost) return; + const next = Math.min(position + SKIP_TIME, duration); + await player.seekTo(next); + setPosition(next); + syncPlayback('seek', next); + }, [player, isHost, position, duration, syncPlayback]); + + const handleBackward = useCallback(async () => { + if (!player || !isHost) return; + const next = Math.max(position - SKIP_TIME, 0); + await player.seekTo(next); + setPosition(next); + syncPlayback('seek', next); + }, [player, isHost, position, syncPlayback]); + + const handleSliderComplete = useCallback( + async (value: number) => { + if (!player) return; + await player.seekTo(value); + setPosition(value); + if (isHost) { + syncPlayback('seek', value); + } + }, + [player, isHost, syncPlayback], + ); + + // --- Leave / End handlers --- + const handleLeave = useCallback(() => { + leaveRoom(); + try { + player.pause(); + } catch {} + navigation.goBack(); + }, [leaveRoom, player, navigation]); + + const handleEnd = useCallback(() => { + endRoom(); + try { + player.pause(); + } catch {} + navigation.goBack(); + }, [endRoom, player, navigation]); + + // --- Room ended by host (listeners auto-navigate back) --- + useEffect(() => { + if (!isHost && !isInRoom && initiatedRef.current) { + // Room was ended by host + Snackbar.show({ + text: 'The host has ended the listening session.', + duration: Snackbar.LENGTH_LONG, + }); + try { + player.pause(); + } catch {} + // Small delay so snackbar is visible + const timer = setTimeout(() => { + if (navigation.canGoBack()) { + navigation.goBack(); + } + }, 1500); + return () => clearTimeout(timer); + } + }, [isInRoom, isHost, navigation, player]); + + // --- Loading state --- + if (isPodcastLoading && !podcast) { + return ; + } + + // --- Waiting for room (before socket responds) --- + if (!isInRoom && !error) { + return ( + + + + + {mode === 'host' + ? 'Creating your listening room...' + : 'Joining the listening session...'} + + + + ); + } + + // --- Cover image --- + const coverImageSource = getFormattedSource( + podcast?.cover_image, + ); + + return ( + + + + {/* Safe area spacer */} + + + {/* Back button */} + { + Alert.alert( + 'Leave Session?', + 'You will leave the listening session.', + [ + {text: 'Stay', style: 'cancel'}, + { + text: 'Leave', + style: 'destructive', + onPress: isHost ? handleEnd : handleLeave, + }, + ], + ); + }} + accessibilityLabel="Go back"> + + + + {/* Room Bar */} + {roomCode && ( + + + + )} + + {/* Cover Image */} + {coverImageSource && ( + + + + )} + + {/* Title & Description */} + + + + + LISTENING TOGETHER + + + + {podcast?.title || 'Health Podcast'} + + {podcast?.description ? ( + + {podcast.description} + + ) : null} + + + {/* Waveform */} + + + + + {/* Progress Slider */} + + + + + {formatSecTime(position)} + + + {formatSecTime(duration)} + + + + + {/* Playback Controls */} + + {!isHost && ( + + + + Host is controlling playback + + + )} + + + {/* Backward */} + + + + + {/* Play / Pause */} + (isPlaying ? handlePause() : handlePlay())} + accessibilityLabel={isPlaying ? 'Pause' : 'Play'}> + + + + {/* Forward */} + + + + + + + {/* Chat Section */} + + + + + {/* Bottom padding */} + + + + + ); +}; + +export default ListenTogetherScreen; + +// --------------------------------------------------------------------------- +// Styles +// --------------------------------------------------------------------------- + +const styles = StyleSheet.create({ + scrollContent: { + paddingHorizontal: 16, + paddingBottom: 20, + }, + safeAreaSpacer: { + height: 50, + }, + backButton: { + width: 42, + height: 42, + borderRadius: 21, + backgroundColor: '#1E293B', + alignItems: 'center', + justifyContent: 'center', + marginBottom: 12, + borderWidth: 1, + borderColor: '#334155', + }, + roomBarContainer: { + marginBottom: 16, + }, + coverContainer: { + borderRadius: 20, + overflow: 'hidden', + marginBottom: 16, + alignItems: 'center', + padding: 12, + }, + coverImage: { + width: '100%', + height: 200, + borderRadius: 14, + resizeMode: 'cover', + }, + infoContainer: { + borderRadius: 16, + padding: 16, + marginBottom: 8, + }, + sliderContainer: { + borderRadius: 16, + padding: 16, + paddingBottom: 12, + marginBottom: 8, + }, + slider: { + width: '100%', + height: 36, + }, + controlsContainer: { + borderRadius: 16, + padding: 16, + marginBottom: 16, + }, + controlBtn: { + width: 60, + height: 60, + borderRadius: 30, + backgroundColor: '#1E293B', + alignItems: 'center', + justifyContent: 'center', + borderWidth: 2, + borderColor: '#334155', + }, + controlBtnDisabled: { + opacity: 0.5, + }, + playBtn: { + width: 80, + height: 80, + borderRadius: 40, + backgroundColor: '#3B82F6', + alignItems: 'center', + justifyContent: 'center', + shadowColor: '#3B82F6', + shadowOffset: {width: 0, height: 8}, + shadowRadius: 20, + shadowOpacity: 0.5, + elevation: 8, + }, + playBtnDisabled: { + backgroundColor: '#475569', + shadowOpacity: 0, + elevation: 0, + }, + chatContainer: { + height: 320, + marginBottom: 8, + }, +}); diff --git a/frontend/src/screens/PodcastDetail.tsx b/frontend/src/screens/PodcastDetail.tsx index d6481a683..6b1512c31 100644 --- a/frontend/src/screens/PodcastDetail.tsx +++ b/frontend/src/screens/PodcastDetail.tsx @@ -743,6 +743,36 @@ useEffect(() => { onPress={handleShare}> + + {/* LISTEN TOGETHER */} + { + if (isGuest) { + navigation.navigate('GuestPlaceholderScreen', { + title: 'Sign In Required', + description: 'Please sign in or sign up to listen together.', + iconName: 'people', + }); + return; + } + if (!isConnected) { + Snackbar.show({ + text: 'You need an internet connection to listen together.', + duration: Snackbar.LENGTH_SHORT, + }); + return; + } + if (podcast) { + navigation.navigate('ListenTogetherScreen', { + trackId: podcast._id, + audioUrl: podcast.audio_url, + mode: 'host', + }); + } + }}> + + ) : ( diff --git a/frontend/src/type.ts b/frontend/src/type.ts index 034f11f9a..1bf9425a8 100644 --- a/frontend/src/type.ts +++ b/frontend/src/type.ts @@ -134,6 +134,15 @@ export type RootStackParamList = { ContributorPage: undefined; OpenSourcePage: undefined; ReadingHistoryScreen: undefined; + ListenTogetherScreen: { + trackId: string; + audioUrl?: string | null; + roomCode?: string; + mode: 'host' | 'listener'; + }; + JoinListenTogetherScreen: { + roomCode?: string; + }; }; export type RedirectTo = { @@ -370,6 +379,16 @@ export type PodcastPlayerScreenProps = StackScreenProps< 'PodcastPlayer' >; +export type ListenTogetherScreenProp = StackScreenProps< + RootStackParamList, + 'ListenTogetherScreen' +>; + +export type JoinListenTogetherScreenProp = StackScreenProps< + RootStackParamList, + 'JoinListenTogetherScreen' +>; + export type HomeScreenHeaderProps = { handlePresentModalPress: () => void; onTextInputChange: (textInput: string) => void; diff --git a/frontend/src/types/ListenTogetherTypes.ts b/frontend/src/types/ListenTogetherTypes.ts new file mode 100644 index 000000000..d245bf159 --- /dev/null +++ b/frontend/src/types/ListenTogetherTypes.ts @@ -0,0 +1,103 @@ +/** + * Listen Together — Type definitions + * + * Types used by the synchronized podcast listening ("Listen Together") feature. + * These cover socket event payloads, room state, chat messages, and participants. + */ + +// --------------------------------------------------------------------------- +// Socket event name constants +// --------------------------------------------------------------------------- + +export const LISTEN_TOGETHER_EVENTS = { + // Client → Server + CREATE_ROOM: 'listen-together:create', + JOIN_ROOM: 'listen-together:join', + LEAVE_ROOM: 'listen-together:leave', + END_ROOM: 'listen-together:end', + SYNC_PLAYBACK: 'listen-together:sync', + SEND_CHAT: 'listen-together:chat', + + // Server → Client + ROOM_CREATED: 'listen-together:room-created', + ROOM_JOINED: 'listen-together:room-joined', + ROOM_ENDED: 'listen-together:room-ended', + PARTICIPANT_JOINED: 'listen-together:participant-joined', + PARTICIPANT_LEFT: 'listen-together:participant-left', + SYNC_UPDATE: 'listen-together:sync-update', + CHAT_MESSAGE: 'listen-together:chat-message', + ERROR: 'listen-together:error', +} as const; + +// --------------------------------------------------------------------------- +// Participant +// --------------------------------------------------------------------------- + +export interface ListenTogetherParticipant { + userId: string; + userHandle: string; + profileImage: string | null; + joinedAt: string; // ISO timestamp +} + +// --------------------------------------------------------------------------- +// Sync payload (sent with every playback action) +// --------------------------------------------------------------------------- + +export type SyncAction = 'play' | 'pause' | 'seek' | 'ended'; + +export interface ListenTogetherSyncPayload { + roomCode: string; + action: SyncAction; + position: number; // seconds + timestamp: number; // Date.now() for clock-drift compensation +} + +// --------------------------------------------------------------------------- +// Chat message +// --------------------------------------------------------------------------- + +export interface ListenTogetherMessage { + id: string; + roomCode: string; + userId: string; + userHandle: string; + profileImage: string | null; + text: string; + sentAt: string; // ISO timestamp +} + +// --------------------------------------------------------------------------- +// Room state (maintained client-side by the hook) +// --------------------------------------------------------------------------- + +export interface ListenTogetherRoom { + roomCode: string; + hostUserId: string; + podcastId: string; + audioUrl: string; + podcastTitle: string; + podcastCoverImage: string; + participants: ListenTogetherParticipant[]; + createdAt: string; +} + +// --------------------------------------------------------------------------- +// Server response payloads +// --------------------------------------------------------------------------- + +export interface RoomCreatedPayload { + roomCode: string; + room: ListenTogetherRoom; +} + +export interface RoomJoinedPayload { + room: ListenTogetherRoom; + currentPosition: number; + isPlaying: boolean; +} + +export interface ListenTogetherError { + code: string; + message: string; +} From 009cfa58b8e47954e6dd663a8595a6e8c1ac1e39 Mon Sep 17 00:00:00 2001 From: Vivek Arya Date: Mon, 6 Jul 2026 11:26:38 +0530 Subject: [PATCH 2/2] fix: address PR review feedback --- .../src/components/ListenTogetherChat.tsx | 13 +++++-- .../src/components/ListenTogetherRoomBar.tsx | 5 +-- frontend/src/hooks/useListenTogether.ts | 20 ++++++++-- frontend/src/screens/ListenTogetherScreen.tsx | 38 +++++++++++-------- 4 files changed, 50 insertions(+), 26 deletions(-) diff --git a/frontend/src/components/ListenTogetherChat.tsx b/frontend/src/components/ListenTogetherChat.tsx index de7ee7899..870a6754a 100644 --- a/frontend/src/components/ListenTogetherChat.tsx +++ b/frontend/src/components/ListenTogetherChat.tsx @@ -18,6 +18,7 @@ import { } from 'react-native'; import {Text, XStack, YStack} from 'tamagui'; import {Ionicons} from '@expo/vector-icons'; +import {useHeaderHeight} from '@react-navigation/elements'; import {ListenTogetherMessage} from '../types/ListenTogetherTypes'; interface ListenTogetherChatProps { @@ -31,6 +32,7 @@ const ListenTogetherChat: React.FC = ({ currentUserId, onSendMessage, }) => { + const headerHeight = useHeaderHeight(); const [inputText, setInputText] = useState(''); const flatListRef = useRef(null); @@ -43,11 +45,16 @@ const ListenTogetherChat: React.FC = ({ const formatTime = (isoString: string) => { try { const date = new Date(isoString); + if (isNaN(date.getTime())) { + console.warn('Invalid date string for chat message:', isoString); + return '--:--'; + } const hours = date.getHours().toString().padStart(2, '0'); const minutes = date.getMinutes().toString().padStart(2, '0'); return `${hours}:${minutes}`; - } catch { - return ''; + } catch (e) { + console.error('Error formatting chat message time:', e, isoString); + return '--:--'; } }; @@ -106,7 +113,7 @@ const ListenTogetherChat: React.FC = ({ + keyboardVerticalOffset={Platform.OS === 'ios' ? headerHeight : 0}> {/* Header */} = ({ onEnd, }) => { const handleCopyCode = useCallback(() => { - // React Native doesn't have a direct Clipboard in Expo without the module, - // so we show a Snackbar with the code for now. + Clipboard.setStringAsync(roomCode); Snackbar.show({ text: `Room code copied: ${roomCode}`, duration: Snackbar.LENGTH_SHORT, diff --git a/frontend/src/hooks/useListenTogether.ts b/frontend/src/hooks/useListenTogether.ts index 61a6e9494..8b634a631 100644 --- a/frontend/src/hooks/useListenTogether.ts +++ b/frontend/src/hooks/useListenTogether.ts @@ -56,8 +56,8 @@ export interface UseListenTogetherReturn { export const useListenTogether = (): UseListenTogetherReturn => { const socket = useSocket(); - const {user_id, user_handle} = useSelector((state: any) => state.user); - const profileImage = useSelector((state: any) => state.user.profile_image ?? null); + const {user_id, user_handle, profile_image} = useSelector((state: any) => state.user); + const profileImage = profile_image ?? null; // --- State --------------------------------------------------------------- const [room, setRoom] = useState(null); @@ -270,6 +270,16 @@ export const useListenTogether = (): UseListenTogetherReturn => { socket.on(LISTEN_TOGETHER_EVENTS.SYNC_UPDATE, onSyncUpdate); socket.on(LISTEN_TOGETHER_EVENTS.CHAT_MESSAGE, onChatMessage); socket.on(LISTEN_TOGETHER_EVENTS.ERROR, onError); + + const onSocketDisconnect = () => { + setError('Disconnected from the server. Attempting to reconnect...'); + }; + const onSocketReconnect = () => { + setError(null); + }; + + socket.on('disconnect', onSocketDisconnect); + socket.on('reconnect', onSocketReconnect); return () => { socket.off(LISTEN_TOGETHER_EVENTS.ROOM_CREATED, onRoomCreated); @@ -280,6 +290,8 @@ export const useListenTogether = (): UseListenTogetherReturn => { socket.off(LISTEN_TOGETHER_EVENTS.SYNC_UPDATE, onSyncUpdate); socket.off(LISTEN_TOGETHER_EVENTS.CHAT_MESSAGE, onChatMessage); socket.off(LISTEN_TOGETHER_EVENTS.ERROR, onError); + socket.off('disconnect', onSocketDisconnect); + socket.off('reconnect', onSocketReconnect); }; }, [socket]); @@ -287,14 +299,14 @@ export const useListenTogether = (): UseListenTogetherReturn => { useEffect(() => { return () => { - if (roomCodeRef.current && socket) { + if (roomCodeRef.current && socket && isInRoom) { socket.emit(LISTEN_TOGETHER_EVENTS.LEAVE_ROOM, { roomCode: roomCodeRef.current, userId: user_id, }); } }; - }, [socket, user_id]); + }, [socket, user_id, isInRoom]); return { room, diff --git a/frontend/src/screens/ListenTogetherScreen.tsx b/frontend/src/screens/ListenTogetherScreen.tsx index a5e097940..63721acd9 100644 --- a/frontend/src/screens/ListenTogetherScreen.tsx +++ b/frontend/src/screens/ListenTogetherScreen.tsx @@ -16,6 +16,7 @@ import { import {Theme, XStack, YStack, Text, ScrollView} from 'tamagui'; import {Ionicons} from '@expo/vector-icons'; import Slider from '@react-native-community/slider'; +// useAudioPlayer is imported from expo-audio, ensuring standard audio playback compatibility import {useAudioPlayer} from 'expo-audio'; import {useSelector} from 'react-redux'; import Snackbar from 'react-native-snackbar'; @@ -37,27 +38,32 @@ import {SyncAction} from '../types/ListenTogetherTypes'; const isAllowedUrl = (urlStr?: string | null): boolean => { if (!urlStr) return false; - if (!urlStr.startsWith('http://') && !urlStr.startsWith('https://')) { - if (urlStr.includes('://')) return false; - return true; - } - try { - const match = urlStr.match(/^https?:\/\/([^/?#:]+)/i); - if (!match) return false; - const hostname = match[1].toLowerCase(); - return ( - hostname === 'uhsocial.in' || - hostname === 'localhost' || - hostname === '10.0.2.2' - ); - } catch { - return false; + // If it starts with http/https, it must be whitelisted explicitly. + if (urlStr.startsWith('http://') || urlStr.startsWith('https://')) { + try { + const match = urlStr.match(/^https?:\/\/([^/?#:]+)/i); + if (!match) return false; + const hostname = match[1].toLowerCase(); + return ( + hostname === 'uhsocial.in' || + hostname === 'localhost' || + hostname === '10.0.2.2' + ); + } catch { + return false; + } } + // For non-http/https, assume it's a path for GET_IMAGE. + // Ensure it doesn't contain protocol separators or directory traversal attempts. + return !urlStr.includes('://') && !urlStr.includes('..') && !urlStr.startsWith('/'); }; const getFormattedSource = (url?: string | null): string | null => { if (!url) return null; - if (!isAllowedUrl(url)) return null; + if (!isAllowedUrl(url)) { + console.warn('Attempted to load disallowed URL:', url); + return null; + } return url.startsWith('http') ? url : `${GET_IMAGE}/${url}`; };