Files
dchain/client-app/app/(app)/compose.tsx
vsecoder a75cbcd224 feat: resource caps, Saved Messages, author walls, docs for node bring-up
Node flags (cmd/node/main.go):
  --max-cpu / --max-ram-mb — Go runtime caps (GOMAXPROCS / GOMEMLIMIT)
  --feed-disk-limit-mb — hard 507 refusal for new post bodies over quota
  --chain-disk-limit-mb — advisory watcher (can't reject blocks without
  breaking consensus; logs WARN every minute)

Client — Saved Messages (self-chat):
  - Auto-created on sign-in, pinned top of chat list, blue bookmark avatar
  - Send short-circuits the relay (no encrypt, no fee, no mailbox hop)
  - Empty state rendered outside inverted FlatList — fixes the mirrored
    "say hi…" on Android RTL-aware layout builds
  - PostCard shows "You" for own posts instead of the self-contact alias

Client — user walls:
  - New route /(app)/feed/author/[pub] with infinite-scroll via
    `created_at` cursor and pull-to-refresh
  - Profile screen gains "View posts" button (universal) next to
    "Open chat" (contact-only)

Feed pipeline:
  - Bump client JPEG quality 0.5 → 0.75 to match server scrubber (Q=75),
    so a 60 KiB compose doesn't balloon past 256 KiB after server re-encode
  - ErrPostTooLarge now wraps with the actual size vs cap, errors.Is
    preserved in the HTTP layer
  - FeedMailbox quota + DiskUsage surface — supports new CLI flag

README:
  - Step-by-step "first node / joiner" section on the landing page,
    full flag tables incl. the new resource-cap group, minimal
    checklists for open/private/low-end deployments
2026-04-19 13:14:47 +03:00

403 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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] ··· [<count / 4000>] [~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<Attachment | null>(null);
const [busy, setBusy] = useState(false);
const [picking, setPicking] = useState(false);
const [balance, setBalance] = useState<number | null>(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<string>();
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 (
<KeyboardAvoidingView
style={{ flex: 1, backgroundColor: '#000000' }}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
{/* Header */}
<View
style={{
paddingTop: insets.top + 8,
paddingBottom: 12,
paddingHorizontal: 14,
flexDirection: 'row',
alignItems: 'center',
borderBottomWidth: 1,
borderBottomColor: '#141414',
}}
>
<Pressable onPress={() => safeBack()} hitSlop={8}>
<Ionicons name="close" size={26} color="#ffffff" />
</Pressable>
<View style={{ flex: 1 }} />
<Pressable
onPress={onPublish}
disabled={!canPublish}
style={({ pressed }) => ({
paddingHorizontal: 18, paddingVertical: 9,
borderRadius: 999,
backgroundColor: canPublish ? (pressed ? '#1a8cd8' : '#1d9bf0') : '#1f1f1f',
})}
>
{busy ? (
<ActivityIndicator color="#ffffff" size="small" />
) : (
<Text
style={{
color: canPublish ? '#ffffff' : '#5a5a5a',
fontWeight: '700',
fontSize: 14,
}}
>
Publish
</Text>
)}
</Pressable>
</View>
<ScrollView
keyboardShouldPersistTaps="handled"
contentContainerStyle={{ paddingHorizontal: 14, paddingTop: 12, paddingBottom: 80 }}
>
{/* Avatar + TextInput row */}
<View style={{ flexDirection: 'row' }}>
<Avatar name={username ?? '?'} address={keyFile?.pub_key} size={40} />
<TextInput
value={content}
onChangeText={setContent}
placeholder="What's happening?"
placeholderTextColor="#5a5a5a"
multiline
maxLength={MAX_CONTENT_LENGTH}
autoFocus
style={{
flex: 1,
marginLeft: 10,
color: '#ffffff',
fontSize: 17,
lineHeight: 22,
minHeight: 120,
paddingTop: 4,
textAlignVertical: 'top',
}}
/>
</View>
{/* Hashtag preview */}
{hashtags.length > 0 && (
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 14, marginLeft: 50 }}>
{hashtags.map(tag => (
<View
key={tag}
style={{
paddingHorizontal: 10, paddingVertical: 4,
borderRadius: 999,
backgroundColor: '#081a2a',
borderWidth: 1, borderColor: '#11385a',
}}
>
<Text style={{ color: '#1d9bf0', fontSize: 12, fontWeight: '600' }}>
#{tag}
</Text>
</View>
))}
</View>
)}
{/* Attachment preview */}
{attach && (
<View style={{ marginTop: 14, marginLeft: 50 }}>
<View
style={{
position: 'relative',
borderRadius: 16,
overflow: 'hidden',
borderWidth: 1,
borderColor: '#1f1f1f',
}}
>
<Image
source={{ uri: attach.uri }}
style={{
width: '100%',
aspectRatio: attach.width && attach.height
? attach.width / attach.height
: 4 / 3,
backgroundColor: '#0a0a0a',
}}
resizeMode="cover"
/>
<Pressable
onPress={() => 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',
})}
>
<Ionicons name="close" size={16} color="#ffffff" />
</Pressable>
</View>
<Text style={{ color: '#6a6a6a', fontSize: 11, marginTop: 6 }}>
{Math.round(attach.size / 1024)} KB · metadata stripped on server
</Text>
</View>
)}
</ScrollView>
{/* Footer: attach / counter / fee */}
<View
style={{
paddingHorizontal: 14,
paddingVertical: 10,
paddingBottom: Math.max(insets.bottom, 10),
borderTopWidth: 1,
borderTopColor: '#141414',
flexDirection: 'row',
alignItems: 'center',
gap: 14,
}}
>
<Pressable
onPress={onPickImage}
disabled={picking || !!attach}
hitSlop={8}
style={({ pressed }) => ({
opacity: pressed || picking || attach ? 0.5 : 1,
})}
>
{picking
? <ActivityIndicator color="#1d9bf0" size="small" />
: <Ionicons name="image-outline" size={22} color="#1d9bf0" />}
</Pressable>
<View style={{ flex: 1 }} />
<Text
style={{
color: totalBytes > 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
</Text>
<View style={{ width: 1, height: 14, backgroundColor: '#1f1f1f' }} />
<Text style={{ color: '#6a6a6a', fontSize: 12 }}>
{formatFee(estimatedFee)}
</Text>
</View>
</KeyboardAvoidingView>
);
}
// ── 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;
}