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:
168
client-app/lib/crypto.ts
Normal file
168
client-app/lib/crypto.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Cryptographic operations for DChain messenger.
|
||||
*
|
||||
* Ed25519 — transaction signing (via TweetNaCl sign)
|
||||
* X25519 — Diffie-Hellman key exchange for NaCl box
|
||||
* NaCl box — authenticated encryption for relay messages
|
||||
*/
|
||||
|
||||
import nacl from 'tweetnacl';
|
||||
import { decodeUTF8, encodeUTF8 } from 'tweetnacl-util';
|
||||
import { getRandomBytes } from 'expo-crypto';
|
||||
import type { KeyFile } from './types';
|
||||
|
||||
// ─── PRNG ─────────────────────────────────────────────────────────────────────
|
||||
// TweetNaCl looks for window.crypto which doesn't exist in React Native/Hermes.
|
||||
// Wire nacl to expo-crypto which uses the platform's secure RNG natively.
|
||||
nacl.setPRNG((output: Uint8Array, length: number) => {
|
||||
const bytes = getRandomBytes(length);
|
||||
for (let i = 0; i < length; i++) output[i] = bytes[i];
|
||||
});
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function hexToBytes(hex: string): Uint8Array {
|
||||
if (hex.length % 2 !== 0) throw new Error('odd hex length');
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function bytesToHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
// ─── Key generation ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate a new identity: Ed25519 signing keys + X25519 encryption keys.
|
||||
* Returns a KeyFile compatible with the Go node format.
|
||||
*/
|
||||
export function generateKeyFile(): KeyFile {
|
||||
// Ed25519 for signing / blockchain identity
|
||||
const signKP = nacl.sign.keyPair();
|
||||
|
||||
// X25519 for NaCl box encryption
|
||||
// nacl.box.keyPair() returns Curve25519 keys
|
||||
const boxKP = nacl.box.keyPair();
|
||||
|
||||
return {
|
||||
pub_key: bytesToHex(signKP.publicKey),
|
||||
priv_key: bytesToHex(signKP.secretKey),
|
||||
x25519_pub: bytesToHex(boxKP.publicKey),
|
||||
x25519_priv: bytesToHex(boxKP.secretKey),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── NaCl box encryption ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Encrypt a plaintext message using NaCl box.
|
||||
* Sender uses their X25519 secret key + recipient's X25519 public key.
|
||||
* Returns { nonce, ciphertext } as hex strings.
|
||||
*/
|
||||
export function encryptMessage(
|
||||
plaintext: string,
|
||||
senderSecretHex: string,
|
||||
recipientPubHex: string,
|
||||
): { nonce: string; ciphertext: string } {
|
||||
const nonce = nacl.randomBytes(nacl.box.nonceLength);
|
||||
const message = decodeUTF8(plaintext);
|
||||
const secretKey = hexToBytes(senderSecretHex);
|
||||
const publicKey = hexToBytes(recipientPubHex);
|
||||
|
||||
const box = nacl.box(message, nonce, publicKey, secretKey);
|
||||
return {
|
||||
nonce: bytesToHex(nonce),
|
||||
ciphertext: bytesToHex(box),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a NaCl box.
|
||||
* Recipient uses their X25519 secret key + sender's X25519 public key.
|
||||
*/
|
||||
export function decryptMessage(
|
||||
ciphertextHex: string,
|
||||
nonceHex: string,
|
||||
senderPubHex: string,
|
||||
recipientSecHex: string,
|
||||
): string | null {
|
||||
try {
|
||||
const ciphertext = hexToBytes(ciphertextHex);
|
||||
const nonce = hexToBytes(nonceHex);
|
||||
const senderPub = hexToBytes(senderPubHex);
|
||||
const secretKey = hexToBytes(recipientSecHex);
|
||||
|
||||
const plain = nacl.box.open(ciphertext, nonce, senderPub, secretKey);
|
||||
if (!plain) return null;
|
||||
return encodeUTF8(plain);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Ed25519 signing ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sign arbitrary data with the Ed25519 private key.
|
||||
* Returns signature as hex.
|
||||
*/
|
||||
export function sign(data: Uint8Array, privKeyHex: string): string {
|
||||
const secretKey = hexToBytes(privKeyHex);
|
||||
const sig = nacl.sign.detached(data, secretKey);
|
||||
return bytesToHex(sig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign arbitrary data with the Ed25519 private key.
|
||||
* Returns signature as base64 — this is the format the Go blockchain node
|
||||
* expects ([]byte fields are base64 in JSON).
|
||||
*/
|
||||
export function signBase64(data: Uint8Array, privKeyHex: string): string {
|
||||
const secretKey = hexToBytes(privKeyHex);
|
||||
const sig = nacl.sign.detached(data, secretKey);
|
||||
return bytesToBase64(sig);
|
||||
}
|
||||
|
||||
/** Encode bytes as base64. Works on Hermes (btoa is available since RN 0.71). */
|
||||
export function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode base64 → bytes. Accepts both standard and URL-safe variants (the
|
||||
* node's /relay/inbox returns `[]byte` fields marshalled via Go's default
|
||||
* json.Marshal which uses standard base64).
|
||||
*/
|
||||
export function base64ToBytes(b64: string): Uint8Array {
|
||||
const binary = atob(b64.replace(/-/g, '+').replace(/_/g, '/'));
|
||||
const out = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an Ed25519 signature.
|
||||
*/
|
||||
export function verify(data: Uint8Array, sigHex: string, pubKeyHex: string): boolean {
|
||||
try {
|
||||
return nacl.sign.detached.verify(data, hexToBytes(sigHex), hexToBytes(pubKeyHex));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Address helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Truncate a long hex address for display: 8...8 */
|
||||
export function shortAddr(hex: string, chars = 8): string {
|
||||
if (hex.length <= chars * 2 + 3) return hex;
|
||||
return `${hex.slice(0, chars)}…${hex.slice(-chars)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user