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:
@@ -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 && (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user