Skip to content

Commit

Permalink
Attachments
Browse files Browse the repository at this point in the history
  • Loading branch information
fjsj committed Jan 15, 2025
1 parent 0c77786 commit e3a856a
Show file tree
Hide file tree
Showing 11 changed files with 403 additions and 60 deletions.
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,7 @@ _dev/

# Cursor prompt
.cursorrules

# Pre-build files
android/
ios/
24 changes: 22 additions & 2 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -42,6 +47,21 @@
{
"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."
}
],
[
"expo-video",
{
"supportsBackgroundPlayback": true,
"supportsPictureInPicture": true
}
]
],
"experiments": {
Expand Down
79 changes: 70 additions & 9 deletions app/(app)/thread/[id].tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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();
Expand All @@ -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(() => {
Expand All @@ -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 (
Expand All @@ -61,7 +115,14 @@ export default function ThreadPage() {
<SafeAreaView className="flex-1 bg-background-50">
<ChatHeader currentThread={thread} getAvatarURL={getAvatarURL} />
<ChatMessageList messages={thread.messages} loading={isLoading} />
<ChatMessageInput message={message} setMessage={setMessage} onSend={handleSendMessage} />
<ChatMessageInput
message={message}
setMessage={setMessage}
onAttachment={handleAttachment}
onSend={handleSendMessage}
isSending={isSending}
isAttaching={isAttaching}
/>
</SafeAreaView>
);
}
1 change: 1 addition & 0 deletions babel.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ module.exports = function (api) {
},
},
],
"react-native-reanimated/plugin",
],
};
};
130 changes: 125 additions & 5 deletions components/ChatMessageBubble.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import { Attachment } from "@medplum/fhirtypes";
import { useMedplumProfile } from "@medplum/react-hooks";
import { UserRound } from "lucide-react-native";
import { View } from "react-native";
import * as FileSystem from "expo-file-system";
import * as Sharing from "expo-sharing";
import { useVideoPlayer } from "expo-video";
import { VideoView } from "expo-video";
import { FileDown, UserRound } from "lucide-react-native";
import { useCallback, useRef, useState } from "react";
import { Pressable, StyleSheet, 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 { formatTime } from "@/utils/datetime";
Expand All @@ -13,11 +22,104 @@ interface ChatMessageBubbleProps {
avatarURL?: string | null;
}

type AttachmentWithUrl = Attachment & { url: string };

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

function VideoAttachment({ attachment }: { attachment: AttachmentWithUrl }) {
const player = useVideoPlayer(attachment.url, (player) => {
player.loop = true;
});
const videoRef = useRef<VideoView>(null);
const [isFullscreen, setIsFullscreen] = useState(false);

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

const handleExitFullscreen = () => {
setIsFullscreen(false);
};

return (
<View className="relative min-w-[50%]">
<VideoView
ref={videoRef}
style={videoStyles.video}
player={player}
nativeControls={isFullscreen}
onFullscreenExit={handleExitFullscreen}
/>
<Pressable
onPress={handlePlayPress}
className="absolute inset-0 items-center justify-center"
></Pressable>
</View>
);
}

async function shareFile(attachment: AttachmentWithUrl) {
try {
const filename = attachment.title || "download";
const downloadResult = await FileSystem.downloadAsync(
attachment.url,
FileSystem.documentDirectory + filename,
);

if (downloadResult.status === 200) {
await Sharing.shareAsync(downloadResult.uri, {
mimeType: attachment.contentType || "application/octet-stream",
dialogTitle: `Share File: ${filename}`,
});
} else {
Alert.alert("Error", "Failed to share file, please try again", [{ text: "OK" }]);
}
} catch {
Alert.alert("Error", "Failed to share file, please try again", [{ text: "OK" }]);
}
}

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

const handleShare = useCallback(async () => {
setIsDownloading(true);
await shareFile(attachment);
setIsDownloading(false);
}, [attachment]);

return (
<Button
className="bg-tertiary-500"
variant="solid"
onPress={handleShare}
disabled={isDownloading}
>
{isDownloading ? (
<ButtonSpinner className="text-white" />
) : (
<ButtonIcon as={FileDown} className="text-typography-600 text-white" />
)}
<ButtonText className="text-sm text-white">{attachment.title || "Attachment"}</ButtonText>
</Button>
);
}

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 hasVideo = message.attachment?.contentType?.startsWith("video/");

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";
Expand All @@ -31,8 +133,26 @@ export function ChatMessageBubble({ message, avatarURL }: ChatMessageBubbleProps
{avatarURL && <AvatarImage source={{ uri: avatarURL }} />}
</Avatar>
<View className={`rounded-xl border p-3 ${bubbleColor} ${borderColor}`}>
<Text className="text-typography-900">{message.text}</Text>
<Text className="text-xs text-typography-600">{formatTime(message.sentAt)}</Text>
{message.text && <Text className="text-typography-900">{message.text}</Text>}
{message.attachment?.url && (
<View className="mt-2">
{hasImage ? (
<Image
key={message.attachment.url}
source={message.attachment.url}
className="rounded-lg"
size="2xl"
resizeMode="contain"
alt={`Attachment ${message.attachment.title}`}
/>
) : hasVideo ? (
<VideoAttachment attachment={message.attachment as AttachmentWithUrl} />
) : (
<FileAttachment attachment={message.attachment as AttachmentWithUrl} />
)}
</View>
)}
<Text className="mt-1 text-xs text-typography-600">{formatTime(message.sentAt)}</Text>
</View>
</View>
</View>
Expand Down
40 changes: 30 additions & 10 deletions components/ChatMessageInput.tsx
Original file line number Diff line number Diff line change
@@ -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<void>;
onSend: () => Promise<void>;
isAttaching: boolean;
isSending: boolean;
}

export function ChatMessageInput({ message, setMessage, onSend }: ChatMessageInputProps) {
export function ChatMessageInput({
message,
setMessage,
onAttachment,
onSend,
isAttaching,
isSending,
}: ChatMessageInputProps) {
return (
<View className="flex-row items-center bg-background-0 p-3">
<TextareaResizable className="flex-1">
<Button
variant="outline"
size="md"
onPress={onAttachment}
disabled={isAttaching}
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..."
value={message}
onChangeText={setMessage}
className="min-h-10 px-3"
className="min-h-10 border-outline-300 px-3"
editable={!isSending}
/>
</TextareaResizable>
<Button
variant="solid"
size="md"
onPress={onSend}
disabled={!message.trim()}
className="ml-3 aspect-square rounded-full bg-success-500 p-2"
disabled={!message.trim() || isSending}
className="ml-3 aspect-square rounded-full bg-success-500 p-2 disabled:bg-background-300"
>
<Icon as={SendIcon} size="sm" className="text-typography-0" />
<ButtonIcon as={SendIcon} size="md" className="text-typography-0" />
</Button>
</View>
);
Expand Down
Loading

0 comments on commit e3a856a

Please sign in to comment.