Skip to content

Commit

Permalink
Merge pull request #17 from vintasoftware/feat/video-attach
Browse files Browse the repository at this point in the history
Video attachments working
  • Loading branch information
fjsj authored Jan 16, 2025
2 parents 79b4636 + a41ae76 commit 4ea9d7b
Show file tree
Hide file tree
Showing 13 changed files with 173 additions and 81 deletions.
7 changes: 7 additions & 0 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@
"cameraPermission": "The app needs camera access when you want to attach media to your messages.",
"microphonePermission": "The app needs microphone access when you want to attach media to your messages."
}
],
[
"expo-video",
{
"supportsBackgroundPlayback": true,
"supportsPictureInPicture": true
}
]
],
"experiments": {
Expand Down
6 changes: 3 additions & 3 deletions app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useMedplumContext } from "@medplum/react-hooks";
import { Redirect, Slot } from "expo-router";
import { SafeAreaView } from "react-native-safe-area-context";
import { View } from "react-native";

import { PractitionerBanner } from "@/components/PractitionerBanner";
import { Spinner } from "@/components/ui/spinner";
Expand All @@ -12,9 +12,9 @@ export default function AppLayout() {

if (medplum.isLoading()) {
return (
<SafeAreaView className="flex-1 items-center justify-center bg-background-50">
<View className="flex-1 items-center justify-center bg-background-50">
<Spinner size="large" />
</SafeAreaView>
</View>
);
}
if (!medplum.getActiveLogin()) {
Expand Down
10 changes: 5 additions & 5 deletions app/(app)/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useMedplumContext } from "@medplum/react-hooks";
import { useRouter } from "expo-router";
import { useCallback, useMemo, useState } from "react";
import { SafeAreaView } from "react-native-safe-area-context";
import { View } from "react-native";

import { CreateThreadModal } from "@/components/CreateThreadModal";
import { ThreadList } from "@/components/ThreadList";
Expand All @@ -28,14 +28,14 @@ export default function Index() {

if (isLoading || isAvatarsLoading) {
return (
<SafeAreaView className="flex-1 items-center justify-center">
<View className="flex-1 items-center justify-center">
<Spinner size="large" />
</SafeAreaView>
</View>
);
}

return (
<SafeAreaView className="flex-1 bg-background-50">
<View className="flex-1 bg-background-50">
<ThreadListHeader onLogout={handleLogout} onCreateThread={() => setIsCreateModalOpen(true)} />
<ThreadList
threads={threads}
Expand All @@ -47,6 +47,6 @@ export default function Index() {
onClose={() => setIsCreateModalOpen(false)}
onCreateThread={createThread}
/>
</SafeAreaView>
</View>
);
}
14 changes: 6 additions & 8 deletions app/(app)/thread/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import { useMedplumContext } from "@medplum/react-hooks";
import * as ImagePicker from "expo-image-picker";
import { router, useLocalSearchParams } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import { Alert } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Alert, View } from "react-native";

import { ChatHeader } from "@/components/ChatHeader";
import { ChatMessageInput } from "@/components/ChatMessageInput";
Expand Down Expand Up @@ -105,24 +104,23 @@ export default function ThreadPage() {

if (!thread || isAvatarsLoading) {
return (
<SafeAreaView className="flex-1 items-center justify-center">
<View className="flex-1 items-center justify-center">
<Spinner size="large" />
</SafeAreaView>
</View>
);
}

return (
<SafeAreaView className="flex-1 bg-background-50">
<View className="flex-1 bg-background-50">
<ChatHeader currentThread={thread} getAvatarURL={getAvatarURL} />
<ChatMessageList messages={thread.messages} loading={isLoading} />
<ChatMessageInput
message={message}
setMessage={setMessage}
onAttachment={handleAttachment}
onSend={handleSendMessage}
isSending={isSending}
isAttaching={isAttaching}
isSending={isSending || isAttaching}
/>
</SafeAreaView>
</View>
);
}
32 changes: 14 additions & 18 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,11 @@ import { MedplumProvider } from "@medplum/react-hooks";
import { makeRedirectUri } from "expo-auth-session";
import { router, Stack } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import { StatusBar } from "expo-status-bar";
import { useEffect } from "react";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { SafeAreaProvider } from "react-native-safe-area-context";

import { GluestackUIProvider } from "@/components/gluestack-ui-provider";
import { SafeAreaView } from "@/components/ui/safe-area-view";
import { oauth2ClientId } from "@/utils/medplum-oauth2";

export const unstable_settings = {
Expand Down Expand Up @@ -48,23 +47,20 @@ export default function RootLayout() {
}, []);

return (
<>
<StatusBar style="dark" translucent={true} />
<SafeAreaView className="h-full md:w-full">
<GluestackUIProvider mode="light">
<SafeAreaProvider>
<MedplumProvider medplum={medplum}>
<GestureHandlerRootView className="flex-1">
<Stack
screenOptions={{
headerShown: false,
// Prevents flickering:
animation: "none",
}}
/>
</GestureHandlerRootView>
</MedplumProvider>
</SafeAreaProvider>
<MedplumProvider medplum={medplum}>
<GestureHandlerRootView className="flex-1">
<Stack
screenOptions={{
headerShown: false,
// Prevents flickering:
animation: "none",
}}
/>
</GestureHandlerRootView>
</MedplumProvider>
</GluestackUIProvider>
</>
</SafeAreaView>
);
}
7 changes: 3 additions & 4 deletions app/sign-in.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@ import {
import { useRouter } from "expo-router";
import * as WebBrowser from "expo-web-browser";
import { useCallback, useState } from "react";
import { Alert, Button } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Alert, Button, View } from "react-native";

import { Spinner } from "@/components/ui/spinner";
import { oauth2ClientId, oAuth2Discovery } from "@/utils/medplum-oauth2";
Expand Down Expand Up @@ -119,9 +118,9 @@ export default function SignIn() {
}, [medplumLogin]);

return (
<SafeAreaView className="flex-1 items-center justify-center">
<View className="flex-1 items-center justify-center">
{isLoading && <Spinner size="large" />}
{!isLoading && <Button title="Connect to Medplum" onPress={handleLogin} />}
</SafeAreaView>
</View>
);
}
74 changes: 66 additions & 8 deletions components/ChatMessageBubble.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { useMedplumProfile } from "@medplum/react-hooks";
import { Image } from "expo-image";
import { FileDown, UserRound } from "lucide-react-native";
import { useCallback, useState } from "react";
import { View } from "react-native";
import { useVideoPlayer } from "expo-video";
import { VideoView } from "expo-video";
import { CirclePlay, FileDown, UserRound } from "lucide-react-native";
import { memo, useCallback, useRef, useState } from "react";
import { Pressable, StyleSheet, View } from "react-native";
import { Alert } from "react-native";

import { Avatar, AvatarImage } from "@/components/ui/avatar";
Expand All @@ -12,13 +14,68 @@ import { Text } from "@/components/ui/text";
import type { ChatMessage } from "@/models/chat";
import type { AttachmentWithUrl } from "@/types/attachment";
import { formatTime } from "@/utils/datetime";
import { shareFile } from "@/utils/media";
import { isMediaExpired, mediaKey, shareFile } from "@/utils/media";

interface ChatMessageBubbleProps {
message: ChatMessage;
avatarURL?: string | null;
}

const mediaStyles = StyleSheet.create({
media: {
width: 150,
height: 266,
},
});

const VideoAttachment = memo(
({ uri }: { uri: string }) => {
const player = useVideoPlayer(uri, (player) => {
player.loop = true;
player.bufferOptions = {
// Reduce buffer for performance:
minBufferForPlayback: 0,
preferredForwardBufferDuration: 5,
};
});
const videoRef = useRef<VideoView>(null);
const [isFullscreen, setIsFullscreen] = useState(false);

const handlePlayPress = useCallback(() => {
if (!player) return;
player.play();
videoRef.current?.enterFullscreen();
setIsFullscreen(true);
}, [player]);

const handleExitFullscreen = useCallback(() => {
player?.pause();
setIsFullscreen(false);
}, [player]);

return (
<View className="relative">
<VideoView
ref={videoRef}
style={mediaStyles.media}
player={player}
nativeControls={isFullscreen}
onFullscreenExit={handleExitFullscreen}
/>
<Pressable
onPress={handlePlayPress}
className="absolute inset-0 items-center justify-center bg-black/50"
>
<Icon as={CirclePlay} size="xl" className="text-typography-0" />
</Pressable>
</View>
);
},
(oldProps: { uri: string }, newProps: { uri: string }) =>
mediaKey(oldProps.uri) === mediaKey(newProps.uri) && !isMediaExpired(oldProps.uri),
);
VideoAttachment.displayName = "VideoAttachment";

function FileAttachment({ attachment }: { attachment: AttachmentWithUrl }) {
const [isDownloading, setIsDownloading] = useState(false);

Expand Down Expand Up @@ -55,6 +112,7 @@ export function ChatMessageBubble({ message, avatarURL }: ChatMessageBubbleProps
const isPatientMessage = message.senderType === "Patient";
const isCurrentUser = message.senderType === profile?.resourceType;
const hasImage = message.attachment?.contentType?.startsWith("image/");
const hasVideo = message.attachment?.contentType?.startsWith("video/");

const wrapperAlignment = isCurrentUser ? "self-end" : "self-start";
const bubbleColor = isPatientMessage ? "bg-secondary-100" : "bg-tertiary-200";
Expand All @@ -73,13 +131,13 @@ export function ChatMessageBubble({ message, avatarURL }: ChatMessageBubbleProps
<View className="mb-1">
{hasImage ? (
<Image
style={{ width: 200, height: 200 }}
contentFit="contain"
key={message.attachment.url}
style={mediaStyles.media}
source={message.attachment.url}
cachePolicy="memory-disk"
contentFit="contain"
alt={`Attachment ${message.attachment.title}`}
/>
) : hasVideo ? (
<VideoAttachment uri={message.attachment.url} />
) : (
<FileAttachment attachment={message.attachment as AttachmentWithUrl} />
)}
Expand Down
6 changes: 2 additions & 4 deletions components/ChatMessageInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ interface ChatMessageInputProps {
setMessage: (message: string) => void;
onAttachment: () => Promise<void>;
onSend: () => Promise<void>;
isAttaching: boolean;
isSending: boolean;
}

Expand All @@ -18,7 +17,6 @@ export function ChatMessageInput({
setMessage,
onAttachment,
onSend,
isAttaching,
isSending,
}: ChatMessageInputProps) {
return (
Expand All @@ -27,14 +25,14 @@ export function ChatMessageInput({
variant="outline"
size="md"
onPress={() => onAttachment()}
disabled={isAttaching}
disabled={isSending}
className="mr-3 aspect-square border-outline-300 p-2 disabled:bg-background-300"
>
<ButtonIcon as={ImageIcon} size="md" className="text-typography-600" />
</Button>
<TextareaResizable size="md" className="flex-1">
<TextareaResizableInput
placeholder="Type a message..."
placeholder={isSending ? "Sending..." : "Type a message..."}
value={message}
onChangeText={setMessage}
className="min-h-10 border-outline-300 px-3"
Expand Down
27 changes: 10 additions & 17 deletions components/ChatMessageList.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useRef } from "react";
import { useCallback } from "react";
import { FlatList, ListRenderItem } from "react-native";

import { useAvatars } from "@/hooks/useAvatars";
Expand All @@ -13,34 +13,27 @@ interface ChatMessageListProps {
}

export function ChatMessageList({ messages, loading }: ChatMessageListProps) {
const flatListRef = useRef<FlatList>(null);
const { getAvatarURL } = useAvatars(messages.map((message) => message.avatarRef));

const renderItem: ListRenderItem<ChatMessage> = ({ item: message }) => (
<ChatMessageBubble
key={message.id}
message={message}
avatarURL={getAvatarURL(message.avatarRef)}
/>
const renderItem: ListRenderItem<ChatMessage> = useCallback(
({ item: message }) => (
<ChatMessageBubble message={message} avatarURL={getAvatarURL(message.avatarRef)} />
),
[getAvatarURL],
);

return (
<FlatList
ref={flatListRef}
data={messages}
className="flex-1 bg-background-50"
data={[...messages].reverse()}
renderItem={renderItem}
keyExtractor={(message) => message.id}
className="flex-1 bg-background-50"
onContentSizeChange={() => {
// Scroll to bottom when content size changes (e.g. new message)
flatListRef.current?.scrollToEnd({ animated: true });
}}
ListFooterComponent={loading ? <LoadingDots /> : null}
showsVerticalScrollIndicator={true}
initialNumToRender={15}
maxToRenderPerBatch={10}
windowSize={5}
removeClippedSubviews
inverted
ListFooterComponent={loading ? <LoadingDots /> : null}
/>
);
}
Loading

0 comments on commit 4ea9d7b

Please sign in to comment.