diff --git a/app.json b/app.json
index 3689da1..e58e7b5 100644
--- a/app.json
+++ b/app.json
@@ -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": {
diff --git a/app/(app)/_layout.tsx b/app/(app)/_layout.tsx
index f145e36..7d177f8 100644
--- a/app/(app)/_layout.tsx
+++ b/app/(app)/_layout.tsx
@@ -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";
@@ -12,9 +12,9 @@ export default function AppLayout() {
if (medplum.isLoading()) {
return (
-
+
-
+
);
}
if (!medplum.getActiveLogin()) {
diff --git a/app/(app)/index.tsx b/app/(app)/index.tsx
index 8904855..9d098fe 100644
--- a/app/(app)/index.tsx
+++ b/app/(app)/index.tsx
@@ -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";
@@ -28,14 +28,14 @@ export default function Index() {
if (isLoading || isAvatarsLoading) {
return (
-
+
-
+
);
}
return (
-
+
setIsCreateModalOpen(true)} />
setIsCreateModalOpen(false)}
onCreateThread={createThread}
/>
-
+
);
}
diff --git a/app/(app)/thread/[id].tsx b/app/(app)/thread/[id].tsx
index 661648b..ebdfce4 100644
--- a/app/(app)/thread/[id].tsx
+++ b/app/(app)/thread/[id].tsx
@@ -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";
@@ -105,14 +104,14 @@ export default function ThreadPage() {
if (!thread || isAvatarsLoading) {
return (
-
+
-
+
);
}
return (
-
+
-
+
);
}
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 13f578b..43f2b18 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -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 = {
@@ -48,23 +47,20 @@ export default function RootLayout() {
}, []);
return (
- <>
-
+
-
-
-
-
-
-
-
+
+
+
+
+
- >
+
);
}
diff --git a/app/sign-in.tsx b/app/sign-in.tsx
index 5392d9f..b2fbbd8 100644
--- a/app/sign-in.tsx
+++ b/app/sign-in.tsx
@@ -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";
@@ -119,9 +118,9 @@ export default function SignIn() {
}, [medplumLogin]);
return (
-
+
{isLoading && }
{!isLoading && }
-
+
);
}
diff --git a/components/ChatMessageBubble.tsx b/components/ChatMessageBubble.tsx
index 239af57..fe698fb 100644
--- a/components/ChatMessageBubble.tsx
+++ b/components/ChatMessageBubble.tsx
@@ -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";
@@ -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(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 (
+
+
+
+
+
+
+ );
+ },
+ (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);
@@ -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";
@@ -73,13 +131,13 @@ export function ChatMessageBubble({ message, avatarURL }: ChatMessageBubbleProps
{hasImage ? (
+ ) : hasVideo ? (
+
) : (
)}
diff --git a/components/ChatMessageInput.tsx b/components/ChatMessageInput.tsx
index 04515b0..a7e762b 100644
--- a/components/ChatMessageInput.tsx
+++ b/components/ChatMessageInput.tsx
@@ -9,7 +9,6 @@ interface ChatMessageInputProps {
setMessage: (message: string) => void;
onAttachment: () => Promise;
onSend: () => Promise;
- isAttaching: boolean;
isSending: boolean;
}
@@ -18,7 +17,6 @@ export function ChatMessageInput({
setMessage,
onAttachment,
onSend,
- isAttaching,
isSending,
}: ChatMessageInputProps) {
return (
@@ -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"
>
(null);
const { getAvatarURL } = useAvatars(messages.map((message) => message.avatarRef));
- const renderItem: ListRenderItem = ({ item: message }) => (
-
+ const renderItem: ListRenderItem = useCallback(
+ ({ item: message }) => (
+
+ ),
+ [getAvatarURL],
);
return (
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 ? : null}
showsVerticalScrollIndicator={true}
initialNumToRender={15}
- maxToRenderPerBatch={10}
windowSize={5}
removeClippedSubviews
+ inverted
+ ListFooterComponent={loading ? : null}
/>
);
}
diff --git a/components/ThreadList.tsx b/components/ThreadList.tsx
index 509ef4c..279583f 100644
--- a/components/ThreadList.tsx
+++ b/components/ThreadList.tsx
@@ -3,7 +3,8 @@ import { Patient } from "@medplum/fhirtypes";
import { useMedplumContext } from "@medplum/react-hooks";
import { useRouter } from "expo-router";
import { PlusIcon, UserRound } from "lucide-react-native";
-import { FlatList } from "react-native";
+import { useCallback } from "react";
+import { FlatList, ListRenderItem } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import Animated, { FadeInDown } from "react-native-reanimated";
@@ -112,6 +113,19 @@ export function ThreadList({ threads, getAvatarURL, onCreateThread }: ThreadList
const { profile } = useMedplumContext();
const isPractitioner = profile?.resourceType === "Practitioner";
+ const renderItem: ListRenderItem = useCallback(
+ ({ item: thread, index }) => (
+ router.push(`/thread/${thread.id}`)}
+ avatarURL={getAvatarURL(thread.getAvatarRef({ profile })) ?? undefined}
+ isPractitioner={isPractitioner}
+ />
+ ),
+ [getAvatarURL, profile, isPractitioner, router],
+ );
+
if (threads.length === 0) {
return (
@@ -127,19 +141,10 @@ export function ThreadList({ threads, getAvatarURL, onCreateThread }: ThreadList
item.id}
- renderItem={({ item, index }) => (
- router.push(`/thread/${item.id}`)}
- avatarURL={getAvatarURL(item.getAvatarRef({ profile })) ?? undefined}
- isPractitioner={isPractitioner}
- />
- )}
+ renderItem={renderItem}
+ keyExtractor={(thread) => thread.id}
showsVerticalScrollIndicator={true}
- initialNumToRender={10}
- maxToRenderPerBatch={10}
+ initialNumToRender={20}
windowSize={5}
removeClippedSubviews
/>
diff --git a/package-lock.json b/package-lock.json
index b896537..715fbf5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -67,6 +67,7 @@
"expo-status-bar": "~2.0.0",
"expo-symbols": "~0.2.0",
"expo-system-ui": "~4.0.6",
+ "expo-video": "~2.0.5",
"expo-web-browser": "~14.0.1",
"lucide-react-native": "^0.469.0",
"nativewind": "^4.1.23",
@@ -10486,6 +10487,17 @@
"expo": "*"
}
},
+ "node_modules/expo-video": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/expo-video/-/expo-video-2.0.5.tgz",
+ "integrity": "sha512-K5Q4bFKtYq0wEC38mckWUYeaTXsmhl6duidhSdbA63VBy6cwxDOk8uPsFPTQD3FXKJg6wFB0z8ZUASSPuUaY5A==",
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*",
+ "react": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/expo-web-browser": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-14.0.1.tgz",
diff --git a/package.json b/package.json
index 0759705..d1ccf2b 100644
--- a/package.json
+++ b/package.json
@@ -113,7 +113,8 @@
"scheduler": "^0.25.0",
"tailwindcss": "^3.4.17",
"use-context-selector": "^2.0.0",
- "expo-image": "~2.0.4"
+ "expo-image": "~2.0.4",
+ "expo-video": "~2.0.5"
},
"devDependencies": {
"@babel/core": "^7.26.0",
diff --git a/utils/media.ts b/utils/media.ts
index 4ed011d..c4ab914 100644
--- a/utils/media.ts
+++ b/utils/media.ts
@@ -3,6 +3,31 @@ import * as Sharing from "expo-sharing";
import type { AttachmentWithUrl } from "@/types/attachment";
+/**
+ * Check AWS media URL isn't expired
+ * @param url - The media URL
+ * @returns true if the media URL is expired, false otherwise
+ */
+export function isMediaExpired(url: string): boolean {
+ const urlObj = new URL(url);
+ const expires = urlObj.searchParams.get("Expires");
+ if (!expires) {
+ return false;
+ }
+ return new Date(parseInt(expires, 10) * 1000) < new Date();
+}
+
+/**
+ * Removes AWS secret parameters from a media URL
+ * @param url - The media URL
+ * @returns The media URL without AWS secret parameters
+ */
+export function mediaKey(url: string): string {
+ const urlObj = new URL(url);
+ urlObj.search = "";
+ return urlObj.toString();
+}
+
/**
* Downloads a file from a given attachment URL and saves it locally
*/