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

@@ -25,6 +25,7 @@ import { useGlobalInbox } from '@/hooks/useGlobalInbox';
import { getWSClient } from '@/lib/ws';
import { NavBar } from '@/components/NavBar';
import { AnimatedSlot } from '@/components/AnimatedSlot';
import { saveContact } from '@/lib/storage';
export default function AppLayout() {
const keyFile = useStore(s => s.keyFile);
@@ -49,6 +50,23 @@ export default function AppLayout() {
useNotifications(); // permission + tap-handler
useGlobalInbox(); // global inbox listener → notifications on new peer msg
// Ensure the Saved Messages (self-chat) contact exists as soon as the user
// is signed in, so it shows up in the chat list without any prior action.
const contacts = useStore(s => s.contacts);
const upsertContact = useStore(s => s.upsertContact);
useEffect(() => {
if (!keyFile) return;
if (contacts.some(c => c.address === keyFile.pub_key)) return;
const saved = {
address: keyFile.pub_key,
x25519Pub: keyFile.x25519_pub,
alias: 'Saved Messages',
addedAt: Date.now(),
};
upsertContact(saved);
saveContact(saved).catch(() => { /* best-effort — re-added next boot anyway */ });
}, [keyFile, contacts, upsertContact]);
useEffect(() => {
const ws = getWSClient();
if (keyFile) ws.setAuthCreds({ pubKey: keyFile.pub_key, privKey: keyFile.priv_key });

View File

@@ -63,6 +63,24 @@ export default function ChatScreen() {
clearContactNotifications(contactAddress);
}, [contactAddress, clearUnread]);
const upsertContact = useStore(s => s.upsertContact);
const isSavedMessages = !!keyFile && contactAddress === keyFile.pub_key;
// Auto-materialise the Saved Messages contact the first time the user
// opens chat-with-self. The contact is stored locally only — no on-chain
// CONTACT_REQUEST needed, since both ends are the same key pair.
useEffect(() => {
if (!isSavedMessages || !keyFile) return;
const existing = contacts.find(c => c.address === keyFile.pub_key);
if (existing) return;
upsertContact({
address: keyFile.pub_key,
x25519Pub: keyFile.x25519_pub,
alias: 'Saved Messages',
addedAt: Date.now(),
});
}, [isSavedMessages, keyFile, contacts, upsertContact]);
const contact = contacts.find(c => c.address === contactAddress);
const chatMsgs = messages[contactAddress ?? ''] ?? [];
const listRef = useRef<FlatList>(null);
@@ -137,9 +155,11 @@ export default function ChatScreen() {
});
}, [contactAddress, setMsgs]);
const name = contact?.username
? `@${contact.username}`
: contact?.alias ?? shortAddr(contactAddress ?? '');
const name = isSavedMessages
? 'Saved Messages'
: contact?.username
? `@${contact.username}`
: contact?.alias ?? shortAddr(contactAddress ?? '');
// ── Compose actions ────────────────────────────────────────────────────
const cancelCompose = useCallback(() => {
@@ -172,7 +192,7 @@ export default function ChatScreen() {
const hasText = !!actualText.trim();
const hasAttach = !!actualAttach;
if (!hasText && !hasAttach) return;
if (!contact.x25519Pub) {
if (!isSavedMessages && !contact.x25519Pub) {
Alert.alert('No encryption key yet', 'The contact has not published their key. Try later.');
return;
}
@@ -188,7 +208,10 @@ export default function ChatScreen() {
setSending(true);
try {
if (hasText) {
// Saved Messages short-circuits the relay entirely — the message never
// leaves the device, so no encryption/fee/network round-trip is needed.
// Regular chats still go through the NaCl + relay pipeline below.
if (hasText && !isSavedMessages) {
const { nonce, ciphertext } = encryptMessage(
actualText.trim(), keyFile.x25519_priv, contact.x25519Pub,
);
@@ -224,7 +247,7 @@ export default function ChatScreen() {
setSending(false);
}
}, [
text, keyFile, contact, composeMode, chatMsgs,
text, keyFile, contact, composeMode, chatMsgs, isSavedMessages,
setMsgs, cancelCompose, appendMsg, pendingAttach,
]);
@@ -411,7 +434,7 @@ export default function ChatScreen() {
hitSlop={4}
style={{ flexDirection: 'row', alignItems: 'center', gap: 8, maxWidth: '100%' }}
>
<Avatar name={name} address={contactAddress} size={28} />
<Avatar name={name} address={contactAddress} size={28} saved={isSavedMessages} />
<View style={{ minWidth: 0, flexShrink: 1 }}>
<Text
numberOfLines={1}
@@ -429,7 +452,7 @@ export default function ChatScreen() {
typing
</Text>
)}
{!peerTyping && !contact?.x25519Pub && (
{!peerTyping && !isSavedMessages && !contact?.x25519Pub && (
<Text style={{ color: '#f0b35a', fontSize: 11 }}>
waiting for key
</Text>
@@ -447,37 +470,49 @@ export default function ChatScreen() {
с "scroll position at bottom" без ручного scrollToEnd, и новые
сообщения (добавляемые в начало reversed-массива) появляются
внизу естественно. Никаких jerk'ов при открытии. */}
<FlatList
ref={listRef}
data={rows}
inverted
keyExtractor={r => r.kind === 'sep' ? r.id : r.msg.id}
renderItem={renderRow}
contentContainerStyle={{ paddingVertical: 10 }}
showsVerticalScrollIndicator={false}
// Lazy render: only mount ~1.5 screens of bubbles initially,
// render further batches as the user scrolls older. Keeps
// initial paint fast on chats with thousands of messages.
initialNumToRender={25}
maxToRenderPerBatch={12}
windowSize={10}
removeClippedSubviews
ListEmptyComponent={() => (
<View style={{
flex: 1, alignItems: 'center', justifyContent: 'center',
paddingHorizontal: 32, gap: 10,
transform: [{ scaleY: -1 }], // inverted flips cells; un-flip empty state
}}>
<Avatar name={name} address={contactAddress} size={72} />
<Text style={{ color: '#ffffff', fontSize: 16, fontWeight: '700', marginTop: 10 }}>
Say hi to {name}
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', lineHeight: 20 }}>
Your messages are end-to-end encrypted.
</Text>
</View>
)}
/>
{rows.length === 0 ? (
// Empty state is rendered as a plain View instead of
// ListEmptyComponent on an inverted FlatList — the previous
// `transform: [{ scaleY: -1 }]` un-flip trick was rendering
// text mirrored on some Android builds (RTL-aware layout),
// giving us the "say hi…" backwards bug.
<View style={{
flex: 1, alignItems: 'center', justifyContent: 'center',
paddingHorizontal: 32, gap: 10,
}}>
<Avatar
name={name}
address={contactAddress}
size={72}
saved={isSavedMessages}
/>
<Text style={{ color: '#ffffff', fontSize: 16, fontWeight: '700', marginTop: 10 }}>
{isSavedMessages ? 'Notes to self' : `Say hi to ${name}`}
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', lineHeight: 20 }}>
{isSavedMessages
? 'Anything you send here stays on your device — use it as a scratchpad for links, drafts, or files.'
: 'Your messages are end-to-end encrypted.'}
</Text>
</View>
) : (
<FlatList
ref={listRef}
data={rows}
inverted
keyExtractor={r => r.kind === 'sep' ? r.id : r.msg.id}
renderItem={renderRow}
contentContainerStyle={{ paddingVertical: 10 }}
showsVerticalScrollIndicator={false}
// Lazy render: only mount ~1.5 screens of bubbles initially,
// render further batches as the user scrolls older. Keeps
// initial paint fast on chats with thousands of messages.
initialNumToRender={25}
maxToRenderPerBatch={12}
windowSize={10}
removeClippedSubviews
/>
)}
{/* Composer — floating, прибит к низу. */}
<View style={{ paddingBottom: Math.max(insets.bottom, 4) + 6, backgroundColor: '#000000' }}>

View File

@@ -28,6 +28,7 @@ export default function ChatsScreen() {
const insets = useSafeAreaInsets();
const contacts = useStore(s => s.contacts);
const messages = useStore(s => s.messages);
const keyFile = useStore(s => s.keyFile);
// Статус подключения: online / connecting / offline.
// Название шапки и цвет pip'а на аватаре зависят от него.
@@ -48,9 +49,14 @@ export default function ChatsScreen() {
return msgs && msgs.length ? msgs[msgs.length - 1] : null;
};
// Сортировка по последней активности.
// Сортировка по последней активности. Saved Messages (self-chat) всегда
// закреплён сверху — это "Избранное", бессмысленно конкурировать с ним
// по recency'и обычным чатам.
const selfAddr = keyFile?.pub_key;
const sorted = useMemo(() => {
return [...contacts]
const saved = selfAddr ? contacts.find(c => c.address === selfAddr) : undefined;
const rest = contacts
.filter(c => c.address !== selfAddr)
.map(c => ({ c, last: lastOf(c) }))
.sort((a, b) => {
const ka = a.last ? a.last.timestamp : a.c.addedAt / 1000;
@@ -58,7 +64,8 @@ export default function ChatsScreen() {
return kb - ka;
})
.map(x => x.c);
}, [contacts, messages]);
return saved ? [saved, ...rest] : rest;
}, [contacts, messages, selfAddr]);
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
@@ -72,6 +79,7 @@ export default function ChatsScreen() {
<ChatTile
contact={item}
lastMessage={lastOf(item)}
saved={item.address === selfAddr}
onPress={() => router.push(`/(app)/chats/${item.address}` as never)}
/>
)}

View File

@@ -42,7 +42,18 @@ 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;
const IMAGE_QUALITY = 0.5; // JPEG Q=50 — small, still readable
// 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;
@@ -131,10 +142,10 @@ export default function ComposeScreen() {
});
const bytes = base64ToBytes(b64);
if (bytes.length > MAX_POST_BYTES - 512) {
if (bytes.length > IMAGE_BUDGET_BYTES) {
Alert.alert(
'Image too large',
`Image is ${Math.round(bytes.length / 1024)} KB but the limit is ${MAX_POST_BYTES / 1024} KB. Try picking a smaller one.`,
`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;
}

View File

@@ -0,0 +1,249 @@
/**
* Author wall — timeline of every post by a single author, newest first.
*
* Route: /(app)/feed/author/[pub]
*
* Entry points:
* - Profile screen "View posts" button.
* - Tapping the author name/avatar inside a PostCard.
*
* Backend: GET /feed/author/{pub}?limit=N[&before=ts]
* — chain-authoritative, returns FeedPostItem[] ordered newest-first.
*
* Pagination: infinite-scroll via onEndReached → appends the next page
* anchored on the oldest timestamp we've seen. Safe to over-fetch because
* the relay caps `limit`.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
View, Text, FlatList, RefreshControl, ActivityIndicator, Pressable,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { router, useLocalSearchParams } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Header } from '@/components/Header';
import { IconButton } from '@/components/IconButton';
import { Avatar } from '@/components/Avatar';
import { PostCard, PostSeparator } from '@/components/feed/PostCard';
import { useStore } from '@/lib/store';
import { fetchAuthorPosts, fetchStats, type FeedPostItem } from '@/lib/feed';
import { getIdentity, type IdentityInfo } from '@/lib/api';
import { safeBack } from '@/lib/utils';
const PAGE = 30;
function shortAddr(a: string, n = 6): string {
if (!a) return '—';
return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}${a.slice(-n)}`;
}
export default function AuthorWallScreen() {
const insets = useSafeAreaInsets();
const { pub } = useLocalSearchParams<{ pub: string }>();
const keyFile = useStore(s => s.keyFile);
const contacts = useStore(s => s.contacts);
const contact = contacts.find(c => c.address === pub);
const isMe = !!keyFile && keyFile.pub_key === pub;
const [posts, setPosts] = useState<FeedPostItem[]>([]);
const [likedSet, setLikedSet] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [exhausted, setExhausted] = useState(false);
const [identity, setIdentity] = useState<IdentityInfo | null>(null);
const seq = useRef(0);
// Identity — for the header's username / avatar seed. Best-effort; the
// screen still works without it.
useEffect(() => {
if (!pub) return;
let cancelled = false;
getIdentity(pub).then(id => { if (!cancelled) setIdentity(id); }).catch(() => {});
return () => { cancelled = true; };
}, [pub]);
const loadLikedFor = useCallback(async (items: FeedPostItem[]) => {
if (!keyFile) return new Set<string>();
const liked = new Set<string>();
for (const p of items) {
const s = await fetchStats(p.post_id, keyFile.pub_key);
if (s?.liked_by_me) liked.add(p.post_id);
}
return liked;
}, [keyFile]);
const load = useCallback(async (isRefresh = false) => {
if (!pub) return;
if (isRefresh) setRefreshing(true);
else setLoading(true);
const id = ++seq.current;
try {
const items = await fetchAuthorPosts(pub, { limit: PAGE });
if (id !== seq.current) return;
setPosts(items);
setExhausted(items.length < PAGE);
const liked = await loadLikedFor(items);
if (id !== seq.current) return;
setLikedSet(liked);
} catch {
if (id !== seq.current) return;
setPosts([]);
setExhausted(true);
} finally {
if (id !== seq.current) return;
setLoading(false);
setRefreshing(false);
}
}, [pub, loadLikedFor]);
useEffect(() => { load(false); }, [load]);
const loadMore = useCallback(async () => {
if (!pub || loadingMore || exhausted || loading) return;
const oldest = posts[posts.length - 1];
if (!oldest) return;
setLoadingMore(true);
try {
const more = await fetchAuthorPosts(pub, { limit: PAGE, before: oldest.created_at });
// De-dup by post_id — defensive against boundary overlap.
const known = new Set(posts.map(p => p.post_id));
const fresh = more.filter(p => !known.has(p.post_id));
if (fresh.length === 0) { setExhausted(true); return; }
setPosts(prev => [...prev, ...fresh]);
if (more.length < PAGE) setExhausted(true);
const liked = await loadLikedFor(fresh);
setLikedSet(set => {
const next = new Set(set);
liked.forEach(v => next.add(v));
return next;
});
} catch {
// Swallow — user can pull-to-refresh to recover.
} finally {
setLoadingMore(false);
}
}, [pub, posts, loadingMore, exhausted, loading, loadLikedFor]);
const onStatsChanged = useCallback(async (postID: string) => {
if (!keyFile) return;
const s = await fetchStats(postID, keyFile.pub_key);
if (!s) return;
setPosts(ps => ps.map(p => p.post_id === postID
? { ...p, likes: s.likes, views: s.views } : p));
setLikedSet(set => {
const next = new Set(set);
if (s.liked_by_me) next.add(postID); else next.delete(postID);
return next;
});
}, [keyFile]);
// "Saved Messages" is a messaging-app label and has no place on a public
// wall — for self we fall back to the real handle (@username if claimed,
// else short-addr), same as any other author.
const displayName = isMe
? (identity?.nickname ? `@${identity.nickname}` : 'You')
: contact?.username
? `@${contact.username}`
: (contact?.alias && contact.alias !== 'Saved Messages')
? contact.alias
: (identity?.nickname ? `@${identity.nickname}` : shortAddr(pub ?? '', 6));
const handle = identity?.nickname ? `@${identity.nickname}` : shortAddr(pub ?? '', 6);
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header
divider
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
title={
<Pressable
onPress={() => pub && router.push(`/(app)/profile/${pub}` as never)}
hitSlop={4}
style={{ flexDirection: 'row', alignItems: 'center', gap: 8, maxWidth: '100%' }}
>
<Avatar name={displayName} address={pub} size={28} />
<View style={{ minWidth: 0, flexShrink: 1 }}>
<Text
numberOfLines={1}
style={{
color: '#ffffff', fontSize: 15, fontWeight: '700', letterSpacing: -0.2,
}}
>
{displayName}
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 11 }} numberOfLines={1}>
{handle !== displayName ? handle : 'Wall'}
</Text>
</View>
</Pressable>
}
/>
<FlatList
data={posts}
keyExtractor={p => p.post_id}
renderItem={({ item }) => (
<PostCard
post={item}
likedByMe={likedSet.has(item.post_id)}
onStatsChanged={onStatsChanged}
/>
)}
ItemSeparatorComponent={PostSeparator}
initialNumToRender={10}
maxToRenderPerBatch={8}
windowSize={7}
removeClippedSubviews
onEndReachedThreshold={0.6}
onEndReached={loadMore}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={() => load(true)}
tintColor="#1d9bf0"
/>
}
ListFooterComponent={
loadingMore ? (
<View style={{ paddingVertical: 18 }}>
<ActivityIndicator color="#1d9bf0" />
</View>
) : null
}
ListEmptyComponent={
loading ? (
<View style={{ paddingTop: 80, alignItems: 'center' }}>
<ActivityIndicator color="#1d9bf0" />
</View>
) : (
<View style={{
flex: 1,
alignItems: 'center', justifyContent: 'center',
paddingHorizontal: 32, paddingVertical: 80,
}}>
<Ionicons name="document-text-outline" size={32} color="#6a6a6a" />
<Text style={{ color: '#ffffff', fontWeight: '700', marginTop: 10 }}>
{isMe ? "You haven't posted yet" : 'No posts yet'}
</Text>
<Text style={{ color: '#8b8b8b', textAlign: 'center', fontSize: 13, marginTop: 6 }}>
{isMe
? 'Tap the compose button on the feed tab to publish your first post.'
: 'This user hasn\'t published any posts on this chain.'}
</Text>
</View>
)
}
contentContainerStyle={
posts.length === 0
? { flexGrow: 1 }
: { paddingTop: 8, paddingBottom: 24 }
}
/>
</View>
);
}

View File

@@ -64,16 +64,17 @@ export default function NewContactScreen() {
if (!addr) { setError(`@${name} is not registered on this chain`); return; }
address = addr;
}
// Block self-lookup — can't message yourself, and the on-chain
// CONTACT_REQUEST tx would go through but serve no purpose.
// Self-lookup: skip the contact-request dance entirely and jump straight
// to Saved Messages (self-chat). No CONTACT_REQUEST tx is needed — the
// chat-with-self flow is purely local storage.
if (keyFile && address.toLowerCase() === keyFile.pub_key.toLowerCase()) {
setError("That's you. You can't send a contact request to yourself.");
router.replace(`/(app)/chats/${keyFile.pub_key}` as never);
return;
}
const identity = await getIdentity(address);
const resolvedAddr = identity?.pub_key ?? address;
if (keyFile && resolvedAddr.toLowerCase() === keyFile.pub_key.toLowerCase()) {
setError("That's you. You can't send a contact request to yourself.");
router.replace(`/(app)/chats/${keyFile.pub_key}` as never);
return;
}
setResolved({

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 && (
<>

View File

@@ -6,6 +6,7 @@
*/
import React from 'react';
import { View, Text } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
export interface AvatarProps {
/** Имя / @username — берём первый символ для placeholder. */
@@ -18,6 +19,11 @@ export interface AvatarProps {
dotColor?: string;
/** Класс для обёртки (position: relative кадр). */
className?: string;
/**
* Saved Messages variant — blue circle with a bookmark glyph, Telegram-style.
* When set, `name`/`address` are ignored for the visual.
*/
saved?: boolean;
}
/** Простое хэширование имени → один из 6 оттенков серого для разнообразия. */
@@ -28,10 +34,10 @@ function pickBg(seed: string): string {
return shades[h % shades.length];
}
export function Avatar({ name, address, size = 48, dotColor, className }: AvatarProps) {
export function Avatar({ name, address, size = 48, dotColor, className, saved }: AvatarProps) {
const seed = (name ?? address ?? '?').replace(/^@/, '');
const initial = seed.charAt(0).toUpperCase() || '?';
const bg = pickBg(seed);
const bg = saved ? '#1d9bf0' : pickBg(seed);
return (
<View className={className} style={{ width: size, height: size, position: 'relative' }}>
@@ -45,16 +51,20 @@ export function Avatar({ name, address, size = 48, dotColor, className }: Avatar
justifyContent: 'center',
}}
>
<Text
style={{
color: '#d0d0d0',
fontSize: size * 0.4,
fontWeight: '600',
includeFontPadding: false,
}}
>
{initial}
</Text>
{saved ? (
<Ionicons name="bookmark" size={size * 0.5} color="#ffffff" />
) : (
<Text
style={{
color: '#d0d0d0',
fontSize: size * 0.4,
fontWeight: '600',
includeFontPadding: false,
}}
>
{initial}
</Text>
)}
</View>
{dotColor && (
<View

View File

@@ -57,10 +57,12 @@ export interface ChatTileProps {
contact: Contact;
lastMessage: Message | null;
onPress: () => void;
/** Render as the Saved Messages tile (blue bookmark avatar, fixed name). */
saved?: boolean;
}
export function ChatTile({ contact: c, lastMessage, onPress }: ChatTileProps) {
const name = displayName(c);
export function ChatTile({ contact: c, lastMessage, onPress, saved }: ChatTileProps) {
const name = saved ? 'Saved Messages' : displayName(c);
const last = lastMessage;
// Визуальный маркер типа чата.
@@ -92,7 +94,8 @@ export function ChatTile({ contact: c, lastMessage, onPress }: ChatTileProps) {
name={name}
address={c.address}
size={44}
dotColor={c.x25519Pub && (!c.kind || c.kind === 'direct') ? '#3ba55d' : undefined}
saved={saved}
dotColor={!saved && c.x25519Pub && (!c.kind || c.kind === 'direct') ? '#3ba55d' : undefined}
/>
<View style={{ flex: 1, marginLeft: 12, minWidth: 0 }}>
@@ -143,9 +146,11 @@ export function ChatTile({ contact: c, lastMessage, onPress }: ChatTileProps) {
>
{last
? lastPreview(last)
: c.x25519Pub
? 'Tap to start encrypted chat'
: 'Waiting for identity…'}
: saved
? 'Your personal notes & files'
: c.x25519Pub
? 'Tap to start encrypted chat'
: 'Waiting for identity…'}
</Text>
{unread !== null && (

View File

@@ -80,11 +80,16 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }:
// Find a display-friendly name for the author. If it's a known contact
// with @username, use that; otherwise short-addr.
//
// `mine` takes precedence over the contact lookup: our own pub key has
// a self-contact entry with alias "Saved Messages" (that's how the
// self-chat tile is rendered), but that label is wrong in the feed —
// posts there should read as "You", not as a messaging-app affordance.
const displayName = useMemo(() => {
if (mine) return 'You';
const c = contacts.find(x => x.address === post.author);
if (c?.username) return `@${c.username}`;
if (c?.alias) return c.alias;
if (mine) return 'You';
return shortAddr(post.author);
}, [contacts, post.author, mine]);

View File

@@ -367,6 +367,39 @@ export interface IdentityInfo {
registered: boolean;
}
/**
* Relay registration info for a node pub key, as returned by
* /api/relays (which comes back as an array of RegisteredRelayInfo).
* We don't wrap the individual lookup on the server — just filter the
* full list client-side. It's bounded (N nodes in the network) and
* cached heavily enough that this is cheaper than a new endpoint.
*/
export interface RegisteredRelayInfo {
pub_key: string;
address: string;
relay: {
x25519_pub_key: string;
fee_per_msg_ut: number;
multiaddr?: string;
};
last_heartbeat?: number; // unix seconds
}
/** GET /api/relays — all relay nodes registered on-chain. */
export async function getRelays(): Promise<RegisteredRelayInfo[]> {
try {
return await get<RegisteredRelayInfo[]>('/api/relays');
} catch {
return [];
}
}
/** Find relay entry for a specific pub key. null if the address isn't a relay. */
export async function getRelayFor(pubKey: string): Promise<RegisteredRelayInfo | null> {
const all = await getRelays();
return all.find(r => r.pub_key === pubKey) ?? null;
}
/** Fetch identity info for any pubkey or DC address. Returns null on 404. */
export async function getIdentity(pubkeyOrAddr: string): Promise<IdentityInfo | null> {
try {