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 && (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 */