feat(client): Twitter-style social feed UI (Phase C of v2.0.0)

Ships the client side of the v2.0.0 feed feature. Folds client-app/
into the monorepo (was previously .gitignored as "tracked separately"
but no separate repo ever existed — for v2.0.0 the client is
first-class).

Feed screens

  app/(app)/feed.tsx — Feed tab
    - Three-way tab strip: Подписки / Для вас / В тренде backed by
      /feed/timeline, /feed/foryou, /feed/trending respectively
    - Default landing tab is "Для вас" — surfaces discovery without
      requiring the user to follow anyone first
    - FlatList with pull-to-refresh + viewability-driven view counter
      bump (posts visible ≥ 60% for ≥ 1s trigger POST /feed/post/…/view)
    - Floating blue compose button → /compose
    - Per-post liked_by_me fetched in batches of 6 after list load

  app/(app)/compose.tsx — post composer modal
    - Fullscreen, Twitter-like header (✕ left, Опубликовать right)
    - Auto-focused multiline TextInput, 4000 char cap
    - Hashtag preview chips that auto-update as you type
    - expo-image-picker + expo-image-manipulator pipeline: resize to
      1080px max-dim, JPEG Q=50 (client-side first-pass compression
      before the mandatory server-side scrub)
    - Live fee estimate + balance guard with a confirmation modal
      ("Опубликовать пост? Цена: 0.00X T · Размер: N KB")
    - Exif: false passed to ImagePicker as an extra privacy layer

  app/(app)/feed/[id].tsx — post detail
    - Full PostCard rendering + detailed info panel (views, likes,
      size, fee, hosting relay, hashtags as tappable chips)
    - Triggers bumpView on mount
    - 410 (on-chain soft-delete) routes back to the feed

  app/(app)/feed/tag/[tag].tsx — hashtag feed

  app/(app)/profile/[address].tsx — rebuilt
    - Twitter-ish profile: avatar, name, address short-form, post count
    - Posts | Инфо tab strip
    - Follow / Unfollow button for non-self profiles (optimistic UI)
    - Edit button on self profile → settings
    - Secondary actions (chat, copy address) when viewing a known contact

Supporting library

  lib/feed.ts — HTTP wrappers + tx builders for every /feed/* endpoint:
    - publishPost (POST /feed/publish, signed)
    - publishAndCommit (publish → on-chain CREATE_POST)
    - fetchPost / fetchStats / bumpView
    - fetchAuthorPosts / fetchTimeline / fetchForYou / fetchTrending /
      fetchHashtag
    - buildCreatePostTx / buildDeletePostTx
    - buildFollowTx / buildUnfollowTx
    - buildLikePostTx / buildUnlikePostTx
    - likePost / unlikePost / followUser / unfollowUser / deletePost
      (high-level helpers that bundle build + submitTx)
    - formatFee, formatRelativeTime, formatCount — Twitter-like display
      helpers

  components/feed/PostCard.tsx — core card component
    - Memoised for performance (N-row re-render on every like elsewhere
      would cost a lot otherwise)
    - Optimistic like toggle with heart-bounce spring animation
    - Hashtag highlighting in body text (tappable → hashtag feed)
    - Long-press context menu (Delete, owner-only)
    - Views / likes / share-link / reply icons in footer row

Navigation cleanup

  - NavBar: removed the SOON pill on the Feed tab (it's shipped now)
  - (app)/_layout: hide NavBar on /compose and /feed/* sub-routes
  - AnimatedSlot: treat /feed/<id>, /feed/tag/<t>, /compose as
    sub-routes so back-swipe-right closes them

Channel removal (client side)

  - lib/types.ts: ContactKind stripped to 'direct' | 'group'; legacy
    'channel' flag removed. `kind` field kept for backward compat with
    existing AsyncStorage records.
  - lib/devSeed.ts: dropped the 5 channel seed contacts.
  - components/ChatTile.tsx: removed channel kindIcon branch.

Dependencies

  - expo-image-manipulator added for client-side image compression.
  - expo-file-system/legacy used for readAsStringAsync (SDK 54 moved
    that API to the legacy sub-path; the new streaming API isn't yet
    stable).

Type check

  - npx tsc --noEmit — clean, 0 errors.

Next (not in this commit)

  - Direct attachment-bytes endpoint on the server so post-detail can
    actually render the image (currently shows placeholder with URL)
  - Cross-relay body fetch via /api/relays + hosting_relay pubkey
  - Mentions (@username) with notifications
  - Full-text search

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
vsecoder
2026-04-18 19:43:55 +03:00
parent 9e86c93fda
commit 5b64ef2560
68 changed files with 23487 additions and 1 deletions

View File

@@ -0,0 +1,595 @@
/**
* Settings screen — sub-route, открывается по tap'у на profile-avatar в
* TabHeader. Использует обычный `<Header>` с back-кнопкой.
*
* Секции:
* 1. Профиль — avatar, @username, short-address, Copy row.
* 2. Username — регистрация в native:username_registry (если не куплено).
* 3. Node — URL + contract ID + Save + Status.
* 4. Account — Export key, Delete account.
*
* Весь Pressable'овый layout живёт на ВНЕШНЕМ View с static style —
* Pressable handle-ит только background change (через вложенный View
* в ({pressed}) callback'е), никаких layout props в callback-style.
* Это лечит web-баг, где Pressable style-функция не применяет
* percentage/padding layout надёжно.
*/
import React, { useState, useEffect } from 'react';
import {
View, Text, ScrollView, TextInput, Alert, Pressable, ActivityIndicator, Share,
} from 'react-native';
import * as Clipboard from 'expo-clipboard';
import { router } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useStore } from '@/lib/store';
import { saveSettings, deleteKeyFile } from '@/lib/storage';
import {
setNodeUrl, getNetStats, resolveUsername, reverseResolve,
buildCallContractTx, submitTx,
USERNAME_REGISTRATION_FEE, MIN_USERNAME_LENGTH, MAX_USERNAME_LENGTH,
humanizeTxError,
} from '@/lib/api';
import { shortAddr } from '@/lib/crypto';
import { formatAmount } from '@/lib/utils';
import { Avatar } from '@/components/Avatar';
import { Header } from '@/components/Header';
import { IconButton } from '@/components/IconButton';
type NodeStatus = 'idle' | 'checking' | 'ok' | 'error';
type IoniconName = React.ComponentProps<typeof Ionicons>['name'];
// ─── Shared layout primitives ─────────────────────────────────────
function SectionLabel({ children }: { children: string }) {
return (
<Text
style={{
color: '#5a5a5a',
fontSize: 11,
letterSpacing: 1.2,
textTransform: 'uppercase',
marginTop: 18,
marginBottom: 8,
paddingHorizontal: 14,
fontWeight: '700',
}}
>
{children}
</Text>
);
}
function Card({ children }: { children: React.ReactNode }) {
return (
<View
style={{
backgroundColor: '#0a0a0a',
borderRadius: 14,
marginHorizontal: 14,
overflow: 'hidden',
borderWidth: 1,
borderColor: '#1f1f1f',
}}
>
{children}
</View>
);
}
/**
* Row — clickable / non-clickable list item внутри Card'а.
*
* Layout живёт на ВНЕШНЕМ контейнере (View если read-only, Pressable
* если tappable). Для pressed-стейта используется вложенный `<View>`
* с background-color, чтобы не полагаться на style-функцию Pressable'а
* (web-баг).
*/
function Row({
icon, label, value, onPress, right, danger, first,
}: {
icon: IoniconName;
label: string;
value?: string;
onPress?: () => void;
right?: React.ReactNode;
danger?: boolean;
first?: boolean;
}) {
const body = (pressed: boolean) => (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 13,
backgroundColor: pressed ? '#151515' : 'transparent',
borderTopWidth: first ? 0 : 1,
borderTopColor: '#1f1f1f',
}}
>
<View
style={{
width: 32,
height: 32,
borderRadius: 8,
backgroundColor: danger ? 'rgba(244,33,46,0.12)' : '#1a1a1a',
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
}}
>
<Ionicons name={icon} size={16} color={danger ? '#f4212e' : '#ffffff'} />
</View>
<View style={{ flex: 1, minWidth: 0 }}>
<Text
style={{
color: danger ? '#f4212e' : '#ffffff',
fontSize: 14,
fontWeight: '600',
}}
>
{label}
</Text>
{value !== undefined && (
<Text numberOfLines={1} style={{ color: '#8b8b8b', fontSize: 12, marginTop: 2 }}>
{value}
</Text>
)}
</View>
{right}
{onPress && !right && (
<Ionicons name="chevron-forward" size={16} color="#5a5a5a" />
)}
</View>
);
if (!onPress) return <View>{body(false)}</View>;
return (
<Pressable onPress={onPress}>
{({ pressed }) => body(pressed)}
</Pressable>
);
}
// ─── Screen ───────────────────────────────────────────────────────
export default function SettingsScreen() {
const insets = useSafeAreaInsets();
const keyFile = useStore(s => s.keyFile);
const setKeyFile = useStore(s => s.setKeyFile);
const settings = useStore(s => s.settings);
const setSettings = useStore(s => s.setSettings);
const username = useStore(s => s.username);
const setUsername = useStore(s => s.setUsername);
const balance = useStore(s => s.balance);
const [nodeUrl, setNodeUrlInput] = useState(settings.nodeUrl);
const [contractId, setContractId] = useState(settings.contractId);
const [nodeStatus, setNodeStatus] = useState<NodeStatus>('idle');
const [peerCount, setPeerCount] = useState<number | null>(null);
const [blockCount, setBlockCount] = useState<number | null>(null);
const [copied, setCopied] = useState(false);
const [savingNode, setSavingNode] = useState(false);
// Username registration state
const [nameInput, setNameInput] = useState('');
const [nameError, setNameError] = useState<string | null>(null);
const [registering, setRegistering] = useState(false);
useEffect(() => { checkNode(); }, []);
useEffect(() => { setContractId(settings.contractId); }, [settings.contractId]);
useEffect(() => {
if (!settings.contractId || !keyFile) { setUsername(null); return; }
(async () => {
const name = await reverseResolve(settings.contractId, keyFile.pub_key);
setUsername(name);
})();
}, [settings.contractId, keyFile, setUsername]);
async function checkNode() {
setNodeStatus('checking');
try {
const stats = await getNetStats();
setNodeStatus('ok');
setPeerCount(stats.peer_count);
setBlockCount(stats.total_blocks);
} catch {
setNodeStatus('error');
}
}
async function saveNode() {
setSavingNode(true);
const url = nodeUrl.trim().replace(/\/$/, '');
setNodeUrl(url);
const next = { nodeUrl: url, contractId: contractId.trim() };
setSettings(next);
await saveSettings(next);
await checkNode();
setSavingNode(false);
Alert.alert('Saved', 'Node settings updated.');
}
async function copyAddress() {
if (!keyFile) return;
await Clipboard.setStringAsync(keyFile.pub_key);
setCopied(true);
setTimeout(() => setCopied(false), 1800);
}
async function exportKey() {
if (!keyFile) return;
try {
await Share.share({
message: JSON.stringify(keyFile, null, 2),
title: 'DChain key file',
});
} catch (e: any) {
Alert.alert('Export failed', e?.message ?? 'Unknown error');
}
}
function logout() {
Alert.alert(
'Delete account',
'Your key will be removed from this device. Make sure you have a backup!',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
await deleteKeyFile();
setKeyFile(null);
router.replace('/');
},
},
],
);
}
const onNameChange = (v: string) => {
const cleaned = v.toLowerCase().replace(/[^a-z0-9_\-]/g, '').slice(0, MAX_USERNAME_LENGTH);
setNameInput(cleaned);
setNameError(null);
};
const nameIsValid = nameInput.length >= MIN_USERNAME_LENGTH && /^[a-z]/.test(nameInput);
async function registerUsername() {
if (!keyFile) return;
const name = nameInput.trim();
if (!nameIsValid) {
setNameError(`Min ${MIN_USERNAME_LENGTH} chars, starts with a-z`);
return;
}
if (!settings.contractId) {
setNameError('No registry contract in node settings');
return;
}
const total = USERNAME_REGISTRATION_FEE + 1000 + 2000;
if (balance < total) {
setNameError(`Need ${formatAmount(total)}, have ${formatAmount(balance)}`);
return;
}
try {
const existing = await resolveUsername(settings.contractId, name);
if (existing) { setNameError(`@${name} already taken`); return; }
} catch { /* ignore */ }
Alert.alert(
`Buy @${name}?`,
`Cost: ${formatAmount(USERNAME_REGISTRATION_FEE)} + fee ${formatAmount(1000)}.\nBinds to your address until released.`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Buy',
onPress: async () => {
setRegistering(true);
setNameError(null);
try {
const tx = buildCallContractTx({
from: keyFile.pub_key,
contractId: settings.contractId,
method: 'register',
args: [name],
amount: USERNAME_REGISTRATION_FEE,
privKey: keyFile.priv_key,
});
await submitTx(tx);
setNameInput('');
Alert.alert('Submitted', 'Registration tx accepted. Name appears in a few seconds.');
let attempts = 0;
const iv = setInterval(async () => {
attempts++;
const got = keyFile
? await reverseResolve(settings.contractId, keyFile.pub_key)
: null;
if (got) { setUsername(got); clearInterval(iv); }
else if (attempts >= 10) clearInterval(iv);
}, 2000);
} catch (e: any) {
setNameError(humanizeTxError(e));
} finally {
setRegistering(false);
}
},
},
],
);
}
const statusColor =
nodeStatus === 'ok' ? '#3ba55d' :
nodeStatus === 'error' ? '#f4212e' :
'#f0b35a';
const statusLabel =
nodeStatus === 'ok' ? 'Connected' :
nodeStatus === 'error' ? 'Unreachable' :
'Checking…';
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header
title="Settings"
divider={false}
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
/>
<ScrollView
contentContainerStyle={{ paddingBottom: 120 }}
showsVerticalScrollIndicator={false}
>
{/* ── Profile ── */}
<SectionLabel>Profile</SectionLabel>
<Card>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
padding: 14,
gap: 14,
}}
>
<Avatar
name={username ?? keyFile?.pub_key ?? '?'}
address={keyFile?.pub_key}
size={56}
/>
<View style={{ flex: 1, minWidth: 0 }}>
{username ? (
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 17 }}>
@{username}
</Text>
<Ionicons name="checkmark-circle" size={15} color="#1d9bf0" style={{ marginLeft: 4 }} />
</View>
) : (
<Text style={{ color: '#8b8b8b', fontSize: 14 }}>No username yet</Text>
)}
<Text
style={{
color: '#8b8b8b',
fontSize: 11,
marginTop: 2,
fontFamily: 'monospace',
}}
numberOfLines={1}
>
{keyFile ? shortAddr(keyFile.pub_key, 10) : '—'}
</Text>
</View>
</View>
<Row
icon={copied ? 'checkmark-outline' : 'copy-outline'}
label={copied ? 'Copied!' : 'Copy address'}
onPress={copyAddress}
right={<View style={{ width: 16 }} />}
/>
</Card>
{/* ── Username (только если ещё нет) ── */}
{!username && (
<>
<SectionLabel>Username</SectionLabel>
<Card>
<View style={{ padding: 14 }}>
<Text style={{ color: '#ffffff', fontWeight: '600', fontSize: 14, marginBottom: 4 }}>
Buy a username
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 12, lineHeight: 17, marginBottom: 10 }}>
Flat {formatAmount(USERNAME_REGISTRATION_FEE)} fee + {formatAmount(1000)} network.
Only a-z, 0-9, _, -. Starts with a letter.
</Text>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#000000',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 4,
borderWidth: 1,
borderColor: nameError ? '#f4212e' : '#1f1f1f',
}}
>
<Text style={{ color: '#5a5a5a', fontSize: 15, marginRight: 2 }}>@</Text>
<TextInput
value={nameInput}
onChangeText={onNameChange}
placeholder="alice"
placeholderTextColor="#5a5a5a"
autoCapitalize="none"
autoCorrect={false}
maxLength={MAX_USERNAME_LENGTH}
style={{
flex: 1,
color: '#ffffff',
fontSize: 15,
paddingVertical: 8,
}}
/>
</View>
{nameError && (
<Text style={{ color: '#f4212e', fontSize: 12, marginTop: 6 }}>
{nameError}
</Text>
)}
<PrimaryButton
onPress={registerUsername}
disabled={registering || !nameIsValid || !settings.contractId}
loading={registering}
label={`Buy @${nameInput || 'username'}`}
style={{ marginTop: 12 }}
/>
</View>
</Card>
</>
)}
{/* ── Node ── */}
<SectionLabel>Node</SectionLabel>
<Card>
<View style={{ padding: 14, gap: 10 }}>
<LabeledInput
label="Node URL"
value={nodeUrl}
onChangeText={setNodeUrlInput}
placeholder="http://localhost:8080"
/>
<LabeledInput
label="Username contract"
value={contractId}
onChangeText={setContractId}
placeholder="auto-discovered via /api/well-known-contracts"
monospace
/>
<PrimaryButton
onPress={saveNode}
disabled={savingNode}
loading={savingNode}
label="Save"
style={{ marginTop: 4 }}
/>
</View>
<Row
icon="pulse-outline"
label="Status"
value={
nodeStatus === 'ok'
? `${statusLabel} · ${blockCount ?? 0} blocks · ${peerCount ?? 0} peers`
: statusLabel
}
right={
<View
style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: statusColor }}
/>
}
/>
</Card>
{/* ── Account ── */}
<SectionLabel>Account</SectionLabel>
<Card>
<Row
icon="download-outline"
label="Export key"
value="Save your private key as JSON"
onPress={exportKey}
first
/>
<Row
icon="trash-outline"
label="Delete account"
value="Remove key from this device"
onPress={logout}
danger
/>
</Card>
</ScrollView>
</View>
);
}
// ─── Form primitives ──────────────────────────────────────────────
function LabeledInput({
label, value, onChangeText, placeholder, monospace,
}: {
label: string;
value: string;
onChangeText: (v: string) => void;
placeholder?: string;
monospace?: boolean;
}) {
return (
<View>
<Text style={{ color: '#8b8b8b', fontSize: 12, marginBottom: 6 }}>{label}</Text>
<TextInput
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor="#5a5a5a"
autoCapitalize="none"
autoCorrect={false}
style={{
color: '#ffffff',
fontSize: monospace ? 13 : 14,
fontFamily: monospace ? 'monospace' : undefined,
backgroundColor: '#000000',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
borderWidth: 1,
borderColor: '#1f1f1f',
}}
/>
</View>
);
}
function PrimaryButton({
label, onPress, disabled, loading, style,
}: {
label: string;
onPress: () => void;
disabled?: boolean;
loading?: boolean;
style?: object;
}) {
return (
<Pressable onPress={onPress} disabled={disabled} style={style}>
{({ pressed }) => (
<View
style={{
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 11,
borderRadius: 999,
backgroundColor: disabled
? '#1a1a1a'
: pressed ? '#1a8cd8' : '#1d9bf0',
}}
>
{loading ? (
<ActivityIndicator color="#ffffff" size="small" />
) : (
<Text
style={{
color: disabled ? '#5a5a5a' : '#ffffff',
fontWeight: '700',
fontSize: 14,
}}
>
{label}
</Text>
)}
</View>
)}
</Pressable>
);
}