/** * Post composer — full-screen modal for writing a new post. * * Twitter-style layout: * Header: [✕] (draft-ish) [Опубликовать button] * Body: [avatar] [multiline TextInput autogrow] * [hashtags preview chips] * [attachment preview + remove button] * Footer: [📷 attach] ··· [] [~fee estimate] * * The flow: * 1. User types content; hashtags auto-parse for preview * 2. (Optional) pick image — client-side compression (expo-image-manipulator) * → resize to 1080px max, JPEG quality 50 * 3. Tap "Опубликовать" → confirmation modal with fee * 4. Confirm → publishAndCommit() → navigate to post detail * * Failure modes: * - Size overflow (>256 KiB): blocked client-side with hint to compress * further or drop attachment * - Insufficient balance: show humanised error from submitTx * - Network down: toast "нет связи, попробуйте снова" */ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { View, Text, TextInput, Pressable, Alert, Image, KeyboardAvoidingView, Platform, ActivityIndicator, ScrollView, Linking, } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { router } from 'expo-router'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import * as ImagePicker from 'expo-image-picker'; import * as ImageManipulator from 'expo-image-manipulator'; import * as FileSystem from 'expo-file-system/legacy'; import { useStore } from '@/lib/store'; import { Avatar } from '@/components/Avatar'; import { publishAndCommit, formatFee } from '@/lib/feed'; import { humanizeTxError, getBalance } from '@/lib/api'; import { safeBack } from '@/lib/utils'; const MAX_CONTENT_LENGTH = 4000; const MAX_POST_BYTES = 256 * 1024; // must match server's MaxPostSize const IMAGE_MAX_DIM = 1080; // Match the server scrubber's JPEG quality (media/scrub.go:ImageJPEGQuality // = 75). If the client re-encodes at a LOWER quality the server re-encode // at 75 inflates the bytes, often 2-3× — so a 60 KiB upload silently blows // past MaxPostSize = 256 KiB mid-flight and `/feed/publish` rejects with // "post body exceeds maximum allowed size". Using the same Q for both // passes keeps the final footprint ~the same as what the user sees in // the composer. const IMAGE_QUALITY = 0.75; // Safety margin on the pre-upload check: the server pass is near-idempotent // at matching Q but not exactly — reserve ~8 KiB for JPEG header / metadata // scaffolding differences so we don't flirt with the hard cap. const IMAGE_BUDGET_BYTES = MAX_POST_BYTES - 8 * 1024; interface Attachment { uri: string; mime: string; size: number; bytes: Uint8Array; width?: number; height?: number; } export default function ComposeScreen() { const insets = useSafeAreaInsets(); const keyFile = useStore(s => s.keyFile); const username = useStore(s => s.username); const [content, setContent] = useState(''); const [attach, setAttach] = useState(null); const [busy, setBusy] = useState(false); const [picking, setPicking] = useState(false); const [balance, setBalance] = useState(null); // Fetch balance once so we can warn before publishing. useEffect(() => { if (!keyFile) return; getBalance(keyFile.pub_key).then(setBalance).catch(() => setBalance(null)); }, [keyFile]); // Estimated fee mirrors server's formula exactly. Displayed to the user // so they aren't surprised by a debit. const estimatedFee = useMemo(() => { const size = (new TextEncoder().encode(content)).length + (attach?.size ?? 0) + 128; return 1000 + size; // base 1000 + 1 µT/byte (matches blockchain constants) }, [content, attach]); const totalBytes = useMemo(() => { return (new TextEncoder().encode(content)).length + (attach?.size ?? 0) + 128; }, [content, attach]); const hashtags = useMemo(() => { const matches = content.match(/#[A-Za-z0-9_\u0400-\u04FF]{1,40}/g) || []; const seen = new Set(); return matches .map(m => m.slice(1).toLowerCase()) .filter(t => !seen.has(t) && seen.add(t)); }, [content]); const canPublish = !busy && (content.trim().length > 0 || attach !== null) && totalBytes <= MAX_POST_BYTES; const onPickImage = async () => { if (picking) return; setPicking(true); try { const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (!perm.granted) { Alert.alert( 'Photo access required', 'Please enable photo library access in Settings.', [ { text: 'Cancel' }, { text: 'Settings', onPress: () => Linking.openSettings() }, ], ); return; } const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, quality: 1, exif: false, // privacy: ask picker not to return EXIF }); if (result.canceled || !result.assets[0]) return; const asset = result.assets[0]; // Client-side compression: resize + re-encode. This is the FIRST // scrub pass — server will do another one (mandatory) before storing. const manipulated = await ImageManipulator.manipulateAsync( asset.uri, [{ resize: { width: IMAGE_MAX_DIM } }], { compress: IMAGE_QUALITY, format: ImageManipulator.SaveFormat.JPEG }, ); // Read the compressed bytes. const b64 = await FileSystem.readAsStringAsync(manipulated.uri, { encoding: FileSystem.EncodingType.Base64, }); const bytes = base64ToBytes(b64); if (bytes.length > IMAGE_BUDGET_BYTES) { Alert.alert( 'Image too large', `Image is ${Math.round(bytes.length / 1024)} KB but the post limit is ${MAX_POST_BYTES / 1024} KB (after server re-encode). Try a smaller picture.`, ); return; } setAttach({ uri: manipulated.uri, mime: 'image/jpeg', size: bytes.length, bytes, width: manipulated.width, height: manipulated.height, }); } catch (e: any) { Alert.alert('Failed', String(e?.message ?? e)); } finally { setPicking(false); } }; const onPublish = async () => { if (!keyFile || !canPublish) return; // Balance guard. if (balance !== null && balance < estimatedFee) { Alert.alert( 'Insufficient balance', `Need ${formatFee(estimatedFee)}, have ${formatFee(balance)}.`, ); return; } Alert.alert( 'Publish post?', `Cost: ${formatFee(estimatedFee)}\nSize: ${Math.round(totalBytes / 1024 * 10) / 10} KB`, [ { text: 'Cancel', style: 'cancel' }, { text: 'Publish', onPress: async () => { setBusy(true); try { const postID = await publishAndCommit({ author: keyFile.pub_key, privKey: keyFile.priv_key, content: content.trim(), attachment: attach?.bytes, attachmentMIME: attach?.mime, }); // Close composer and open the new post. router.replace(`/(app)/feed/${postID}` as never); } catch (e: any) { Alert.alert('Failed to publish', humanizeTxError(e)); } finally { setBusy(false); } }, }, ], ); }; return ( {/* Header */} safeBack()} hitSlop={8}> ({ paddingHorizontal: 18, paddingVertical: 9, borderRadius: 999, backgroundColor: canPublish ? (pressed ? '#1a8cd8' : '#1d9bf0') : '#1f1f1f', })} > {busy ? ( ) : ( Publish )} {/* Avatar + TextInput row */} {/* Hashtag preview */} {hashtags.length > 0 && ( {hashtags.map(tag => ( #{tag} ))} )} {/* Attachment preview */} {attach && ( setAttach(null)} hitSlop={8} style={({ pressed }) => ({ position: 'absolute', top: 8, right: 8, width: 28, height: 28, borderRadius: 14, backgroundColor: pressed ? 'rgba(0,0,0,0.9)' : 'rgba(0,0,0,0.75)', alignItems: 'center', justifyContent: 'center', })} > {Math.round(attach.size / 1024)} KB · metadata stripped on server )} {/* Footer: attach / counter / fee */} ({ opacity: pressed || picking || attach ? 0.5 : 1, })} > {picking ? : } MAX_POST_BYTES ? '#f4212e' : totalBytes > MAX_POST_BYTES * 0.85 ? '#f0b35a' : '#6a6a6a', fontSize: 12, fontWeight: '600', }} > {Math.round(totalBytes / 1024 * 10) / 10} / {MAX_POST_BYTES / 1024} KB ≈ {formatFee(estimatedFee)} ); } // ── Helpers ──────────────────────────────────────────────────────────── function base64ToBytes(b64: string): Uint8Array { const binary = atob(b64.replace(/-/g, '+').replace(/_/g, '/')); const out = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i); return out; }