feat(mobile): AttachmentImage component with on-demand decrypt + cache

This commit is contained in:
byGalax
2026-05-14 00:18:11 +02:00
parent 415fa10ed0
commit 153e40bdcb
@@ -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<string | null>(() => getCachedAttachment(handle.id) ?? null);
const [error, setError] = useState<string | null>(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<number>).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 (
<View style={[styles.placeholder, { aspectRatio: aspect }]}>
<Text style={styles.errorText}>🔒 {error}</Text>
</View>
);
}
if (!dataUrl) {
return (
<View style={[styles.placeholder, { aspectRatio: aspect }]}>
<ActivityIndicator color={colors.accent} />
</View>
);
}
return (
<Image
source={{ uri: dataUrl }}
style={[styles.image, { aspectRatio: aspect }]}
resizeMode="cover"
/>
);
}
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 },
});