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
This commit is contained in:
vsecoder
2026-04-19 13:14:47 +03:00
parent e6f3d2bcf8
commit a75cbcd224
18 changed files with 870 additions and 102 deletions

View File

@@ -13,7 +13,7 @@
* push stack, so tapping Back returns the user to whatever screen
* pushed them here (feed card tap, chat header tap, etc.).
*/
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import {
View, Text, ScrollView, Pressable, ActivityIndicator,
} from 'react-native';
@@ -27,8 +27,11 @@ import { Avatar } from '@/components/Avatar';
import { Header } from '@/components/Header';
import { IconButton } from '@/components/IconButton';
import { followUser, unfollowUser } from '@/lib/feed';
import { humanizeTxError } from '@/lib/api';
import { safeBack } from '@/lib/utils';
import {
humanizeTxError, getBalance, getIdentity, getRelayFor,
type IdentityInfo, type RegisteredRelayInfo,
} from '@/lib/api';
import { safeBack, formatAmount } from '@/lib/utils';
function shortAddr(a: string, n = 10): string {
if (!a) return '—';
@@ -46,10 +49,35 @@ export default function ProfileScreen() {
const [followingBusy, setFollowingBusy] = useState(false);
const [copied, setCopied] = useState(false);
// On-chain enrichment — fetched once per address mount.
const [balanceUT, setBalanceUT] = useState<number | null>(null);
const [identity, setIdentity] = useState<IdentityInfo | null>(null);
const [relay, setRelay] = useState<RegisteredRelayInfo | null>(null);
const [loadingChain, setLoadingChain] = useState(true);
const isMe = !!keyFile && keyFile.pub_key === address;
const displayName = contact?.username
? `@${contact.username}`
: contact?.alias ?? (isMe ? 'You' : shortAddr(address ?? '', 6));
useEffect(() => {
if (!address) return;
let cancelled = false;
setLoadingChain(true);
Promise.all([
getBalance(address).catch(() => 0),
getIdentity(address).catch(() => null),
getRelayFor(address).catch(() => null),
]).then(([bal, id, rel]) => {
if (cancelled) return;
setBalanceUT(bal);
setIdentity(id);
setRelay(rel);
}).finally(() => { if (!cancelled) setLoadingChain(false); });
return () => { cancelled = true; };
}, [address]);
const displayName = isMe
? 'Saved Messages'
: contact?.username
? `@${contact.username}`
: contact?.alias ?? shortAddr(address ?? '', 6);
const copyAddress = async () => {
if (!address) return;
@@ -94,7 +122,7 @@ export default function ProfileScreen() {
<ScrollView contentContainerStyle={{ padding: 14, paddingBottom: insets.bottom + 30 }}>
{/* ── Hero: avatar + Follow button ──────────────────────────── */}
<View style={{ flexDirection: 'row', alignItems: 'flex-end', marginBottom: 12 }}>
<Avatar name={displayName} address={address} size={72} />
<Avatar name={displayName} address={address} size={72} saved={isMe} />
<View style={{ flex: 1 }} />
{!isMe ? (
<Pressable
@@ -131,16 +159,18 @@ export default function ProfileScreen() {
</Pressable>
) : (
<Pressable
onPress={() => router.push('/(app)/settings' as never)}
onPress={() => keyFile && router.push(`/(app)/chats/${keyFile.pub_key}` as never)}
style={({ pressed }) => ({
paddingHorizontal: 18, paddingVertical: 9,
paddingHorizontal: 16, paddingVertical: 9,
borderRadius: 999,
flexDirection: 'row', alignItems: 'center', gap: 6,
backgroundColor: pressed ? '#1a1a1a' : '#111111',
borderWidth: 1, borderColor: '#1f1f1f',
})}
>
<Ionicons name="bookmark" size={13} color="#f0b35a" />
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 13 }}>
Edit
Saved Messages
</Text>
</Pressable>
)}
@@ -159,13 +189,14 @@ export default function ProfileScreen() {
)}
</View>
{/* Open chat — single CTA, full width, icon inline with text.
Only when we know this is a contact (direct chat exists). */}
{!isMe && contact && (
{/* Action row — View posts is universal (anyone can have a wall,
even non-contacts). Open chat appears alongside only when this
address is already a direct-chat contact. */}
<View style={{ flexDirection: 'row', gap: 8, marginTop: 14 }}>
<Pressable
onPress={openChat}
onPress={() => address && router.push(`/(app)/feed/author/${address}` as never)}
style={({ pressed }) => ({
marginTop: 14,
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
@@ -176,12 +207,34 @@ export default function ProfileScreen() {
borderWidth: 1, borderColor: '#1f1f1f',
})}
>
<Ionicons name="chatbubble-outline" size={15} color="#ffffff" />
<Ionicons name="document-text-outline" size={15} color="#ffffff" />
<Text style={{ color: '#ffffff', fontWeight: '600', fontSize: 14 }}>
Open chat
View posts
</Text>
</Pressable>
)}
{!isMe && contact && (
<Pressable
onPress={openChat}
style={({ pressed }) => ({
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
paddingVertical: 11,
borderRadius: 999,
backgroundColor: pressed ? '#1a1a1a' : '#111111',
borderWidth: 1, borderColor: '#1f1f1f',
})}
>
<Ionicons name="chatbubble-outline" size={15} color="#ffffff" />
<Text style={{ color: '#ffffff', fontWeight: '600', fontSize: 14 }}>
Open chat
</Text>
</Pressable>
)}
</View>
{/* ── Info card ───────────────────────────────────────────────── */}
<View
@@ -225,6 +278,63 @@ export default function ProfileScreen() {
/>
</Pressable>
{/* Username — shown if the on-chain identity record has one.
Different from contact.username (which may be a local alias). */}
{identity?.nickname ? (
<>
<Divider />
<InfoRow
label="Username"
value={`@${identity.nickname}`}
icon="at-outline"
/>
</>
) : null}
{/* DC address — the human-readable form of the pub key. */}
{identity?.address ? (
<>
<Divider />
<InfoRow
label="DC address"
value={identity.address}
icon="pricetag-outline"
/>
</>
) : null}
{/* Balance — always shown once fetched. */}
<Divider />
<InfoRow
label="Balance"
value={loadingChain
? '…'
: `${formatAmount(balanceUT ?? 0)} UT`}
icon="wallet-outline"
/>
{/* Relay node — shown only if this address is a registered relay. */}
{relay && (
<>
<Divider />
<InfoRow
label="Relay node"
value={`${formatAmount(relay.relay.fee_per_msg_ut)} UT / msg`}
icon="radio-outline"
/>
{relay.last_heartbeat ? (
<>
<Divider />
<InfoRow
label="Last seen"
value={new Date(relay.last_heartbeat * 1000).toLocaleString()}
icon="pulse-outline"
/>
</>
) : null}
</>
)}
{/* Encryption status */}
{contact && (
<>