Files
dchain/client-app/app/(app)/profile/[address].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

435 lines
14 KiB
TypeScript

/**
* Profile screen — info card about any address (yours or someone else's),
* plus a Follow/Unfollow button. Posts are intentionally NOT shown here
* — this screen is chat-oriented ("who is on the other side of this
* conversation"); the feed tab + /feed/author/{pub} is where you go to
* browse someone's timeline.
*
* Route:
* /(app)/profile/<ed25519-hex>
*
* Back behaviour:
* Nested Stack layout in app/(app)/profile/_layout.tsx preserves the
* push stack, so tapping Back returns the user to whatever screen
* pushed them here (feed card tap, chat header tap, etc.).
*/
import React, { useEffect, useState } from 'react';
import {
View, Text, ScrollView, Pressable, ActivityIndicator,
} from 'react-native';
import { router, useLocalSearchParams } from 'expo-router';
import * as Clipboard from 'expo-clipboard';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useStore } from '@/lib/store';
import { Avatar } from '@/components/Avatar';
import { Header } from '@/components/Header';
import { IconButton } from '@/components/IconButton';
import { followUser, unfollowUser } from '@/lib/feed';
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 '—';
return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}${a.slice(-n)}`;
}
export default function ProfileScreen() {
const insets = useSafeAreaInsets();
const { address } = useLocalSearchParams<{ address: string }>();
const contacts = useStore(s => s.contacts);
const keyFile = useStore(s => s.keyFile);
const contact = contacts.find(c => c.address === address);
const [following, setFollowing] = useState(false);
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;
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;
await Clipboard.setStringAsync(address);
setCopied(true);
setTimeout(() => setCopied(false), 1800);
};
const openChat = () => {
if (!address) return;
router.replace(`/(app)/chats/${address}` as never);
};
const onToggleFollow = async () => {
if (!keyFile || !address || isMe || followingBusy) return;
setFollowingBusy(true);
const wasFollowing = following;
setFollowing(!wasFollowing);
try {
if (wasFollowing) {
await unfollowUser({ from: keyFile.pub_key, privKey: keyFile.priv_key, target: address });
} else {
await followUser({ from: keyFile.pub_key, privKey: keyFile.priv_key, target: address });
}
} catch (e: any) {
setFollowing(wasFollowing);
// Surface the error via alert — feed lib already formats humanizeTxError.
alert(humanizeTxError(e));
} finally {
setFollowingBusy(false);
}
};
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header
title="Profile"
divider
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
/>
<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} saved={isMe} />
<View style={{ flex: 1 }} />
{!isMe ? (
<Pressable
onPress={onToggleFollow}
disabled={followingBusy}
style={({ pressed }) => ({
paddingHorizontal: 18, paddingVertical: 9,
borderRadius: 999,
backgroundColor: following
? (pressed ? '#1a1a1a' : '#111111')
: (pressed ? '#e7e7e7' : '#ffffff'),
borderWidth: following ? 1 : 0,
borderColor: '#1f1f1f',
minWidth: 120,
alignItems: 'center',
})}
>
{followingBusy ? (
<ActivityIndicator
size="small"
color={following ? '#ffffff' : '#000000'}
/>
) : (
<Text
style={{
color: following ? '#ffffff' : '#000000',
fontWeight: '700',
fontSize: 13,
}}
>
{following ? 'Following' : 'Follow'}
</Text>
)}
</Pressable>
) : (
<Pressable
onPress={() => keyFile && router.push(`/(app)/chats/${keyFile.pub_key}` as never)}
style={({ pressed }) => ({
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 }}>
Saved Messages
</Text>
</Pressable>
)}
</View>
{/* Name + verified tick */}
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Text
style={{ color: '#ffffff', fontSize: 22, fontWeight: '800', letterSpacing: -0.3 }}
numberOfLines={1}
>
{displayName}
</Text>
{contact?.username && (
<Ionicons name="checkmark-circle" size={18} color="#1d9bf0" style={{ marginLeft: 5 }} />
)}
</View>
{/* 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={() => address && router.push(`/(app)/feed/author/${address}` as never)}
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="document-text-outline" size={15} color="#ffffff" />
<Text style={{ color: '#ffffff', fontWeight: '600', fontSize: 14 }}>
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
style={{
marginTop: 18,
borderRadius: 14,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
overflow: 'hidden',
}}
>
{/* Address — entire row is tappable → copies */}
<Pressable
onPress={copyAddress}
style={({ pressed }) => ({
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14, paddingVertical: 12,
backgroundColor: pressed ? '#0f0f0f' : 'transparent',
})}
>
<Text style={{ color: '#8b8b8b', fontSize: 13, flex: 1 }}>
Address
</Text>
<Text
style={{
color: copied ? '#3ba55d' : '#ffffff',
fontSize: 13,
fontFamily: 'monospace',
fontWeight: '600',
}}
numberOfLines={1}
>
{copied ? 'Copied' : shortAddr(address ?? '')}
</Text>
<Ionicons
name={copied ? 'checkmark' : 'copy-outline'}
size={14}
color={copied ? '#3ba55d' : '#6a6a6a'}
style={{ marginLeft: 8 }}
/>
</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 && (
<>
<Divider />
<InfoRow
label="Encryption"
value={contact.x25519Pub
? 'end-to-end (NaCl)'
: 'key not published yet'}
danger={!contact.x25519Pub}
icon={contact.x25519Pub ? 'lock-closed' : 'lock-open'}
/>
<Divider />
<InfoRow
label="Added"
value={new Date(contact.addedAt).toLocaleDateString()}
icon="calendar-outline"
/>
{/* Group-only: participant count. DMs always have exactly two
people so the row would be noise. Groups would show real
member count here from chain state once v2.1.0 ships groups. */}
{contact.kind === 'group' && (
<>
<Divider />
<InfoRow
label="Members"
value="—"
icon="people-outline"
/>
</>
)}
</>
)}
</View>
{!contact && !isMe && (
<Text style={{
color: '#6a6a6a',
fontSize: 12,
textAlign: 'center',
marginTop: 14,
paddingHorizontal: 24,
lineHeight: 17,
}}>
This user isn't in your contacts yet. Tap "Follow" to see their posts in your feed, or add them as a chat contact via @username.
</Text>
)}
</ScrollView>
</View>
);
}
function Divider() {
return <View style={{ height: 1, backgroundColor: '#1f1f1f' }} />;
}
function InfoRow({
label, value, icon, danger,
}: {
label: string;
value: string;
icon?: React.ComponentProps<typeof Ionicons>['name'];
danger?: boolean;
}) {
return (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 12,
}}
>
{icon && (
<Ionicons
name={icon}
size={14}
color={danger ? '#f0b35a' : '#6a6a6a'}
style={{ marginRight: 8 }}
/>
)}
<Text style={{ color: '#8b8b8b', fontSize: 13, flex: 1 }}>{label}</Text>
<Text
style={{
color: danger ? '#f0b35a' : '#ffffff',
fontSize: 13,
fontWeight: '600',
}}
numberOfLines={1}
>
{value}
</Text>
</View>
);
}