feat(mobile): AttachmentImage component with on-demand decrypt + cache
This commit is contained in:
@@ -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 },
|
||||
});
|
||||
Reference in New Issue
Block a user