diff --git a/apps/mobile/components/AttachmentImage.tsx b/apps/mobile/components/AttachmentImage.tsx new file mode 100644 index 0000000..a219283 --- /dev/null +++ b/apps/mobile/components/AttachmentImage.tsx @@ -0,0 +1,94 @@ +import { chat } from '@chat-app/shared'; +import type { AttachmentHandle } from '@chat-app/shared/chat'; +import { Buffer } from 'buffer'; +import { useEffect, useState } from 'react'; +import { ActivityIndicator, Image, StyleSheet, Text, View } from 'react-native'; + +import { getCachedAttachment, setCachedAttachment } from '../lib/attachmentCache'; +import { supabase } from '../lib/supabase'; +import { colors } from '../theme/colors'; + +interface Props { + handle: AttachmentHandle; + ownDeviceId: string; + ownPrivateKey: Uint8Array; +} + +// Decrypts an encrypted image attachment on first mount and renders it +// inline. Subsequent mounts hit the in-memory cache. Failure (key not +// shared yet for this device, network error, etc.) shows a small error +// placeholder rather than crashing the parent message bubble. +export function AttachmentImage({ handle }: Props) { + const [dataUrl, setDataUrl] = useState(() => getCachedAttachment(handle.id) ?? null); + const [error, setError] = useState(null); + + useEffect(() => { + if (dataUrl) return; + let cancelled = false; + void (async () => { + try { + const blob = await chat.downloadAndDecryptAttachment({ + client: supabase, + handle, + }); + const bytes = new Uint8Array(await blob.arrayBuffer()); + const b64 = Buffer.from(bytes as unknown as ArrayLike).toString('base64'); + const url = 'data:' + handle.mimeType + ';base64,' + b64; + if (cancelled) return; + setCachedAttachment(handle.id, url); + setDataUrl(url); + } catch (err: unknown) { + if (cancelled) return; + setError(err instanceof Error ? err.message : 'decrypt failed'); + } + })(); + return () => { + cancelled = true; + }; + }, [dataUrl, handle]); + + // Aspect ratio honoured if available; fall back to a 4:3 placeholder. + const aspect = + handle.width && handle.height && handle.height > 0 ? handle.width / handle.height : 4 / 3; + + if (error) { + return ( + + 🔒 {error} + + ); + } + if (!dataUrl) { + return ( + + + + ); + } + return ( + + ); +} + +const styles = StyleSheet.create({ + image: { + width: '100%', + borderRadius: 8, + marginTop: 4, + }, + placeholder: { + width: '100%', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.surface, + borderRadius: 8, + borderColor: colors.border, + borderWidth: 1, + marginTop: 4, + }, + errorText: { color: colors.danger, fontSize: 12 }, +});