From ca98987eb40f712a7d03db694bbaba5fea022b80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fl=C3=A1vio=20Juvenal?= Date: Tue, 14 Jan 2025 14:35:02 -0300 Subject: [PATCH] Attachments --- .gitignore | 4 ++ app.json | 17 ++++- app/(app)/thread/[id].tsx | 79 +++++++++++++++++++--- babel.config.js | 1 + components/ChatMessageBubble.tsx | 61 +++++++++++++++-- components/ChatMessageInput.tsx | 40 +++++++++--- contexts/ChatContext.tsx | 109 +++++++++++++++++++++++-------- metro.config.js | 3 +- models/chat.ts | 12 +++- package-lock.json | 41 ++++++++++-- package.json | 5 +- types/attachment.ts | 3 + utils/media.ts | 37 +++++++++++ 13 files changed, 354 insertions(+), 58 deletions(-) create mode 100644 types/attachment.ts create mode 100644 utils/media.ts diff --git a/.gitignore b/.gitignore index 9b8a891..d8892c4 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,7 @@ _dev/ # Cursor prompt .cursorrules + +# Pre-build files +android/ +ios/ diff --git a/app.json b/app.json index 57e0723..3689da1 100644 --- a/app.json +++ b/app.json @@ -13,13 +13,18 @@ "supportsTablet": true, "config": { "usesNonExemptEncryption": false - } + }, + "bundleIdentifier": "com.vinta.healthapp" }, "android": { "adaptiveIcon": { "foregroundImage": "./assets/images/adaptive-icon.png", "backgroundColor": "#ffffff" - } + }, + "permissions": [ + "android.permission.RECORD_AUDIO" + ], + "package": "com.vinta.healthapp" }, "web": { "bundler": "metro", @@ -42,6 +47,14 @@ { "requireAuthentication": false } + ], + [ + "expo-image-picker", + { + "photosPermission": "The app needs media access when you want to attach media to your messages.", + "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." + } ] ], "experiments": { diff --git a/app/(app)/thread/[id].tsx b/app/(app)/thread/[id].tsx index c94f530..661648b 100644 --- a/app/(app)/thread/[id].tsx +++ b/app/(app)/thread/[id].tsx @@ -1,6 +1,8 @@ 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 { ChatHeader } from "@/components/ChatHeader"; @@ -10,6 +12,35 @@ import { Spinner } from "@/components/ui/spinner"; import { useAvatars } from "@/hooks/useAvatars"; import { useSingleThread } from "@/hooks/useSingleThread"; +async function getAttachment() { + try { + // Request permissions if needed + const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (!permissionResult.granted) { + Alert.alert( + "Permission Required", + "Please grant media library access to attach images and videos.", + ); + return null; + } + + // Pick media + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ["images", "videos", "livePhotos"], + quality: 1, + allowsMultipleSelection: false, + }); + if (!result.canceled && result.assets[0]) { + return result.assets[0]; + } + return null; + } catch (error) { + Alert.alert("Error", "Failed to attach media. Please try again."); + console.error("Error getting attachment:", error); + return null; + } +} + export default function ThreadPage() { const { id } = useLocalSearchParams<{ id: string }>(); const { profile } = useMedplumContext(); @@ -20,6 +51,8 @@ export default function ThreadPage() { thread?.getAvatarRef({ profile }), ]); const [message, setMessage] = useState(""); + const [isAttaching, setIsAttaching] = useState(false); + const [isSending, setIsSending] = useState(false); // If thread is not loading and the thread undefined, redirect to the index page useEffect(() => { @@ -38,16 +71,37 @@ export default function ThreadPage() { }); }, [thread, markMessageAsRead]); - const handleSendMessage = useCallback(async () => { + const handleSendMessage = useCallback( + async (attachment?: ImagePicker.ImagePickerAsset) => { + if (!thread) return; + setIsSending(true); + const existingMessage = message; + setMessage(""); + + try { + await sendMessage({ + threadId: thread.id, + message: existingMessage, + attachment, + }); + } catch { + setMessage(existingMessage); + } finally { + setIsSending(false); + } + }, + [thread, message, sendMessage], + ); + + const handleAttachment = useCallback(async () => { if (!thread) return; - const existingMessage = message; - setMessage(""); - try { - await sendMessage({ threadId: thread.id, message: existingMessage }); - } catch { - setMessage(existingMessage); + setIsAttaching(true); + const attachment = await getAttachment(); + setIsAttaching(false); + if (attachment) { + await handleSendMessage(attachment); } - }, [thread, message, sendMessage]); + }, [thread, handleSendMessage]); if (!thread || isAvatarsLoading) { return ( @@ -61,7 +115,14 @@ export default function ThreadPage() { - + ); } diff --git a/babel.config.js b/babel.config.js index 6e2f9da..70da522 100644 --- a/babel.config.js +++ b/babel.config.js @@ -22,6 +22,7 @@ module.exports = function (api) { }, }, ], + "react-native-reanimated/plugin", ], }; }; diff --git a/components/ChatMessageBubble.tsx b/components/ChatMessageBubble.tsx index 46fb488..373844d 100644 --- a/components/ChatMessageBubble.tsx +++ b/components/ChatMessageBubble.tsx @@ -1,23 +1,61 @@ import { useMedplumProfile } from "@medplum/react-hooks"; -import { UserRound } from "lucide-react-native"; +import { FileDown, UserRound } from "lucide-react-native"; +import { useCallback, useState } from "react"; import { View } from "react-native"; +import { Alert } from "react-native"; import { Avatar, AvatarImage } from "@/components/ui/avatar"; +import { Button, ButtonIcon, ButtonSpinner, ButtonText } from "@/components/ui/button"; import { Icon } from "@/components/ui/icon"; +import { Image } from "@/components/ui/image"; 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"; interface ChatMessageBubbleProps { message: ChatMessage; avatarURL?: string | null; } +function FileAttachment({ attachment }: { attachment: AttachmentWithUrl }) { + const [isDownloading, setIsDownloading] = useState(false); + + const handleShare = useCallback(async () => { + setIsDownloading(true); + try { + await shareFile(attachment); + } catch { + Alert.alert("Error", "Failed to share file, please try again", [{ text: "OK" }]); + } finally { + setIsDownloading(false); + } + }, [attachment]); + + return ( + + ); +} + export function ChatMessageBubble({ message, avatarURL }: ChatMessageBubbleProps) { const profile = useMedplumProfile(); - const isPatientMessage = message.senderType === "Patient"; const isCurrentUser = message.senderType === profile?.resourceType; + const hasImage = message.attachment?.contentType?.startsWith("image/"); + const wrapperAlignment = isCurrentUser ? "self-end" : "self-start"; const bubbleColor = isPatientMessage ? "bg-secondary-100" : "bg-tertiary-200"; const borderColor = isPatientMessage ? "border-secondary-200" : "border-tertiary-300"; @@ -31,8 +69,23 @@ export function ChatMessageBubble({ message, avatarURL }: ChatMessageBubbleProps {avatarURL && } - {message.text} - {formatTime(message.sentAt)} + {message.attachment?.url && ( + + {hasImage ? ( + {`Attachment + ) : ( + + )} + + )} + {message.text && {message.text}} + {formatTime(message.sentAt)} diff --git a/components/ChatMessageInput.tsx b/components/ChatMessageInput.tsx index 8a29354..2319815 100644 --- a/components/ChatMessageInput.tsx +++ b/components/ChatMessageInput.tsx @@ -1,34 +1,54 @@ -import { SendIcon } from "lucide-react-native"; +import { ImageIcon, SendIcon } from "lucide-react-native"; import { TextareaResizable, TextareaResizableInput } from "@/components/textarea-resizable"; -import { Button } from "@/components/ui/button"; -import { Icon } from "@/components/ui/icon"; +import { Button, ButtonIcon } from "@/components/ui/button"; import { View } from "@/components/ui/view"; interface ChatMessageInputProps { message: string; setMessage: (message: string) => void; - onSend: () => void; + onAttachment: () => Promise; + onSend: () => Promise; + isAttaching: boolean; + isSending: boolean; } -export function ChatMessageInput({ message, setMessage, onSend }: ChatMessageInputProps) { +export function ChatMessageInput({ + message, + setMessage, + onAttachment, + onSend, + isAttaching, + isSending, +}: ChatMessageInputProps) { return ( - + + ); diff --git a/contexts/ChatContext.tsx b/contexts/ChatContext.tsx index 847eb53..d2d4553 100644 --- a/contexts/ChatContext.tsx +++ b/contexts/ChatContext.tsx @@ -5,8 +5,15 @@ import { ProfileResource, QueryTypes, } from "@medplum/core"; -import { Bundle, Communication, Patient } from "@medplum/fhirtypes"; +import { + Attachment, + Bundle, + Communication, + CommunicationPayload, + Patient, +} from "@medplum/fhirtypes"; import { useMedplumContext, useSubscription } from "@medplum/react-hooks"; +import * as ImagePicker from "expo-image-picker"; import { useCallback, useEffect, useMemo, useState } from "react"; import { createContext } from "use-context-selector"; @@ -145,18 +152,34 @@ async function createThreadMessageComm({ profile, message, threadId, + attachment, }: { medplum: MedplumClient; profile: ProfileResource; message: string; threadId: string; + attachment?: Attachment; }): Promise { + const payload: CommunicationPayload[] = []; + + // Add text message if provided + if (message.trim()) { + payload.push({ contentString: message.trim() }); + } + + // Add attachment if provided + if (attachment) { + payload.push({ + contentAttachment: attachment, + }); + } + return await medplum.createResource({ resourceType: "Communication", status: "in-progress", sent: new Date().toISOString(), sender: createReference(profile), - payload: [{ contentString: message.trim() }], + payload, partOf: [{ reference: `Communication/${threadId}` }], } satisfies Communication); } @@ -169,7 +192,15 @@ interface ChatContextType { reconnecting: boolean; createThread: (topic: string) => Promise; receiveThread: (threadId: string) => Promise; - sendMessage: ({ threadId, message }: { threadId: string; message: string }) => Promise; + sendMessage: ({ + threadId, + message, + attachment, + }: { + threadId: string; + message?: string; + attachment?: ImagePicker.ImagePickerAsset; + }) => Promise; markMessageAsRead: ({ threadId, messageId, @@ -366,32 +397,58 @@ export function ChatProvider({ ); const sendMessage = useCallback( - async ({ threadId, message }: { threadId: string; message: string }) => { - if (!message.trim() || !profile) return; - - // Create the message - const newCommunication = await createThreadMessageComm({ - medplum, - profile, - message, - threadId: threadId, - }); + async ({ + threadId, + message, + attachment, + }: { + threadId: string; + message?: string; + attachment?: ImagePicker.ImagePickerAsset; + }) => { + if (!profile) return; + if (!message?.trim() && !attachment) return; - // Touch the thread last changed date - // to ensure useSubscription will trigger for message receivers - await touchThreadLastChanged({ - medplum, - threadId: threadId, - value: newCommunication.sent!, - }); + try { + let uploadedAttachment; + if (attachment) { + // Upload the file to Medplum + const response = await fetch(attachment.uri); + const blob = await response.blob(); + uploadedAttachment = await medplum.createAttachment({ + data: blob, + filename: attachment.fileName ?? undefined, + contentType: attachment.mimeType ?? "application/octet-stream", + }); + } - // Update the thread messages - setThreadCommMap((prev) => { - const existing = prev.get(threadId) || []; - return new Map([...prev, [threadId, syncResourceArray(existing, newCommunication)]]); - }); + // Create the message + const newCommunication = await createThreadMessageComm({ + medplum, + profile, + message: message ?? "", + threadId, + attachment: uploadedAttachment, + }); + + // Touch the thread last changed date + await touchThreadLastChanged({ + medplum, + threadId, + value: newCommunication.sent!, + }); + + // Update the thread messages + setThreadCommMap((prev) => { + const existing = prev.get(threadId) || []; + return new Map([...prev, [threadId, syncResourceArray(existing, newCommunication)]]); + }); + } catch (err) { + onError?.(err as Error); + throw err; + } }, - [profile, medplum], + [profile, medplum, onError], ); const markMessageAsRead = useCallback( diff --git a/metro.config.js b/metro.config.js index b0963fe..4c3c46d 100644 --- a/metro.config.js +++ b/metro.config.js @@ -1,6 +1,7 @@ const { getDefaultConfig } = require("expo/metro-config"); const { withNativeWind } = require("nativewind/metro"); +const { wrapWithReanimatedMetroConfig } = require("react-native-reanimated/metro-config"); const config = getDefaultConfig(__dirname); -module.exports = withNativeWind(config, { input: "./global.css" }); +module.exports = wrapWithReanimatedMetroConfig(withNativeWind(config, { input: "./global.css" })); diff --git a/models/chat.ts b/models/chat.ts index d699b29..7b8436f 100644 --- a/models/chat.ts +++ b/models/chat.ts @@ -1,5 +1,5 @@ import { ProfileResource } from "@medplum/core"; -import { Communication, Patient, Practitioner, Reference } from "@medplum/fhirtypes"; +import { Attachment, Communication, Patient, Practitioner, Reference } from "@medplum/fhirtypes"; export class ChatMessage { readonly originalCommunication: Communication; @@ -22,6 +22,16 @@ export class ChatMessage { return this.originalCommunication.payload?.[0]?.contentString || ""; } + get attachment(): Attachment | undefined { + // find the first attachment in the payload and return it + for (const payload of this.originalCommunication.payload || []) { + if (payload.contentAttachment) { + return payload.contentAttachment; + } + } + return undefined; + } + get senderType(): "Patient" | "Practitioner" { return this.originalCommunication.sender?.reference?.includes("Patient") ? "Patient" diff --git a/package-lock.json b/package-lock.json index 9dc8354..0dc7815 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,11 +53,14 @@ "expo-constants": "~17.0.3", "expo-crypto": "~14.0.1", "expo-dev-client": "~5.0.8", + "expo-file-system": "~18.0.7", "expo-font": "~13.0.2", "expo-haptics": "~14.0.0", + "expo-image-picker": "~16.0.4", "expo-linking": "~7.0.3", "expo-router": "~4.0.15", "expo-secure-store": "~14.0.0", + "expo-sharing": "~13.0.1", "expo-splash-screen": "~0.29.18", "expo-standard-web-crypto": "^2.0.0", "expo-status-bar": "~2.0.0", @@ -71,7 +74,7 @@ "react-native": "0.76.5", "react-native-css-interop": "^0.1.22", "react-native-gesture-handler": "^2.21.2", - "react-native-reanimated": "^3.16.6", + "react-native-reanimated": "~3.16.1", "react-native-safe-area-context": "^5.0.0", "react-native-screens": "~4.4.0", "react-native-svg": "^15.10.1", @@ -10160,9 +10163,9 @@ } }, "node_modules/expo-file-system": { - "version": "18.0.6", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-18.0.6.tgz", - "integrity": "sha512-gGEwIJCXV3/wpIJ/wRyhmieLOSAY7HeFFjb+wEfHs04aE63JYR+rXXV4b7rBpEh1ZgNV9U91zfet/iQG7J8HBQ==", + "version": "18.0.7", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-18.0.7.tgz", + "integrity": "sha512-6PpbQfogMXdzOsJzlJayy5qf40IfIHhudtAOzr32RlRYL4Hkmk3YcR9jG0PWQ0rklJfAhbAdP63yOcN+wDgzaA==", "license": "MIT", "dependencies": { "web-streams-polyfill": "^3.3.2" @@ -10194,6 +10197,27 @@ "expo": "*" } }, + "node_modules/expo-image-loader": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-5.0.0.tgz", + "integrity": "sha512-Eg+5FHtyzv3Jjw9dHwu2pWy4xjf8fu3V0Asyy42kO+t/FbvW/vjUixpTjPtgKQLQh+2/9Nk4JjFDV6FwCnF2ZA==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-image-picker": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-16.0.4.tgz", + "integrity": "sha512-LeTdQxL+kS8wiFOf+mTxDZmr0Ok3KwcpvCaDnXwCdCQuszVd4sSml7lg+oaJfkWj793u74M+8j0hQaEDHCgkxg==", + "license": "MIT", + "dependencies": { + "expo-image-loader": "~5.0.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-json-utils": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-0.14.0.tgz", @@ -10363,6 +10387,15 @@ "expo": "*" } }, + "node_modules/expo-sharing": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-13.0.1.tgz", + "integrity": "sha512-qych3Nw65wlFcnzE/gRrsdtvmdV0uF4U4qVMZBJYPG90vYyWh2QM9rp1gVu0KWOBc7N8CC2dSVYn4/BXqJy6Xw==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-splash-screen": { "version": "0.29.18", "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-0.29.18.tgz", diff --git a/package.json b/package.json index c7e12a4..023ff95 100644 --- a/package.json +++ b/package.json @@ -83,11 +83,14 @@ "expo-constants": "~17.0.3", "expo-crypto": "~14.0.1", "expo-dev-client": "~5.0.8", + "expo-file-system": "~18.0.7", "expo-font": "~13.0.2", "expo-haptics": "~14.0.0", + "expo-image-picker": "~16.0.4", "expo-linking": "~7.0.3", "expo-router": "~4.0.15", "expo-secure-store": "~14.0.0", + "expo-sharing": "~13.0.1", "expo-splash-screen": "~0.29.18", "expo-standard-web-crypto": "^2.0.0", "expo-status-bar": "~2.0.0", @@ -101,7 +104,7 @@ "react-native": "0.76.5", "react-native-css-interop": "^0.1.22", "react-native-gesture-handler": "^2.21.2", - "react-native-reanimated": "^3.16.6", + "react-native-reanimated": "~3.16.1", "react-native-safe-area-context": "^5.0.0", "react-native-screens": "~4.4.0", "react-native-svg": "^15.10.1", diff --git a/types/attachment.ts b/types/attachment.ts new file mode 100644 index 0000000..aa146c9 --- /dev/null +++ b/types/attachment.ts @@ -0,0 +1,3 @@ +import { Attachment } from "@medplum/fhirtypes"; + +export type AttachmentWithUrl = Attachment & { url: string }; diff --git a/utils/media.ts b/utils/media.ts new file mode 100644 index 0000000..4ed011d --- /dev/null +++ b/utils/media.ts @@ -0,0 +1,37 @@ +import * as FileSystem from "expo-file-system"; +import * as Sharing from "expo-sharing"; + +import type { AttachmentWithUrl } from "@/types/attachment"; + +/** + * Downloads a file from a given attachment URL and saves it locally + */ +export async function downloadFile( + attachment: AttachmentWithUrl, +): Promise { + // Extract unique filename by removing query parameters and getting the last path segment + // since URL is like this: https://storage.medplum.com/binary//? + const url = new URL(attachment.url); + let filename = `${url.pathname.split("/").pop()}-${attachment.title}`.trim(); + if (!filename) { + filename = `download-at-${new Date().toISOString()}`; + } + return FileSystem.downloadAsync(attachment.url, FileSystem.documentDirectory + filename); +} + +/** + * Downloads and opens the native share dialog for a file attachment + * @throws Error if download or sharing fails + */ +export async function shareFile(attachment: AttachmentWithUrl): Promise { + const downloadResult = await downloadFile(attachment); + + if (downloadResult.status !== 200) { + throw new Error("Failed to download file"); + } + + await Sharing.shareAsync(downloadResult.uri, { + mimeType: attachment.contentType || "application/octet-stream", + dialogTitle: `Share File: ${attachment.title}`, + }); +}