95 lines
2.8 KiB
TypeScript
95 lines
2.8 KiB
TypeScript
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 },
|
|
});
|