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>
371 lines
12 KiB
TypeScript
371 lines
12 KiB
TypeScript
/**
|
||
* PostCard — Twitter-style feed row.
|
||
*
|
||
* Layout (top-to-bottom, left-to-right):
|
||
*
|
||
* [avatar 44] [@author · time · ⋯ menu]
|
||
* [post text body with #tags + @mentions highlighted]
|
||
* [optional attachment preview]
|
||
* [💬 0 🔁 link ❤️ likes 👁 views]
|
||
*
|
||
* Interaction model:
|
||
* - Tap anywhere except controls → navigate to post detail
|
||
* - Tap author/avatar → profile
|
||
* - Double-tap the post body → like (with a short heart-bounce animation)
|
||
* - Long-press → context menu (copy, share link, delete-if-mine)
|
||
*
|
||
* Performance notes:
|
||
* - Memoised. Feed lists re-render often (after every like, view bump,
|
||
* new post), but each card only needs to update when ITS own stats
|
||
* change. We use shallow prop comparison + stable key on post_id.
|
||
* - Stats are passed in by parent (fetched once per refresh), not
|
||
* fetched here — avoids N /stats requests per timeline render.
|
||
*/
|
||
import React, { useState, useCallback, useMemo } from 'react';
|
||
import {
|
||
View, Text, Pressable, Alert, Animated, Image,
|
||
} from 'react-native';
|
||
import { Ionicons } from '@expo/vector-icons';
|
||
import { router } from 'expo-router';
|
||
|
||
import { Avatar } from '@/components/Avatar';
|
||
import { useStore } from '@/lib/store';
|
||
import type { FeedPostItem } from '@/lib/feed';
|
||
import {
|
||
formatRelativeTime, formatCount, likePost, unlikePost, deletePost, fetchStats,
|
||
} from '@/lib/feed';
|
||
|
||
export interface PostCardProps {
|
||
post: FeedPostItem;
|
||
/** true = current user has liked this post (used for filled heart). */
|
||
likedByMe?: boolean;
|
||
/** Called after a successful like/unlike so parent can refresh stats. */
|
||
onStatsChanged?: (postID: string) => void;
|
||
/** Called after delete so parent can drop the card from the list. */
|
||
onDeleted?: (postID: string) => void;
|
||
/** Compact (no attachment, less padding) — used in nested thread context. */
|
||
compact?: boolean;
|
||
}
|
||
|
||
function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }: PostCardProps) {
|
||
const keyFile = useStore(s => s.keyFile);
|
||
const contacts = useStore(s => s.contacts);
|
||
|
||
// Optimistic local state — immediate response to tap, reconciled after tx.
|
||
const [localLiked, setLocalLiked] = useState<boolean>(!!likedByMe);
|
||
const [localLikeCount, setLocalLikeCount] = useState<number>(post.likes);
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
React.useEffect(() => {
|
||
setLocalLiked(!!likedByMe);
|
||
setLocalLikeCount(post.likes);
|
||
}, [likedByMe, post.likes]);
|
||
|
||
// Heart bounce animation when liked (Twitter-style).
|
||
const heartScale = useMemo(() => new Animated.Value(1), []);
|
||
const animateHeart = useCallback(() => {
|
||
heartScale.setValue(0.6);
|
||
Animated.spring(heartScale, {
|
||
toValue: 1,
|
||
friction: 3,
|
||
tension: 120,
|
||
useNativeDriver: true,
|
||
}).start();
|
||
}, [heartScale]);
|
||
|
||
const mine = !!keyFile && keyFile.pub_key === post.author;
|
||
|
||
// Find a display-friendly name for the author. If it's a known contact
|
||
// with @username, use that; otherwise short-addr.
|
||
const displayName = useMemo(() => {
|
||
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]);
|
||
|
||
const onToggleLike = useCallback(async () => {
|
||
if (!keyFile || busy) return;
|
||
setBusy(true);
|
||
const wasLiked = localLiked;
|
||
// Optimistic update.
|
||
setLocalLiked(!wasLiked);
|
||
setLocalLikeCount(c => c + (wasLiked ? -1 : 1));
|
||
animateHeart();
|
||
try {
|
||
if (wasLiked) {
|
||
await unlikePost({ from: keyFile.pub_key, privKey: keyFile.priv_key, postID: post.post_id });
|
||
} else {
|
||
await likePost({ from: keyFile.pub_key, privKey: keyFile.priv_key, postID: post.post_id });
|
||
}
|
||
// Refresh stats from server so counts reconcile (on-chain is delayed
|
||
// by block time; server returns current cached count).
|
||
setTimeout(() => onStatsChanged?.(post.post_id), 1500);
|
||
} catch (e: any) {
|
||
// Roll back optimistic update.
|
||
setLocalLiked(wasLiked);
|
||
setLocalLikeCount(c => c + (wasLiked ? 1 : -1));
|
||
Alert.alert('Не удалось', String(e?.message ?? e));
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}, [keyFile, busy, localLiked, post.post_id, animateHeart, onStatsChanged]);
|
||
|
||
const onOpenDetail = useCallback(() => {
|
||
router.push(`/(app)/feed/${post.post_id}` as never);
|
||
}, [post.post_id]);
|
||
|
||
const onOpenAuthor = useCallback(() => {
|
||
router.push(`/(app)/profile/${post.author}` as never);
|
||
}, [post.author]);
|
||
|
||
const onLongPress = useCallback(() => {
|
||
if (!keyFile) return;
|
||
const options: Array<{ label: string; destructive?: boolean; onPress: () => void }> = [];
|
||
if (mine) {
|
||
options.push({
|
||
label: 'Удалить пост',
|
||
destructive: true,
|
||
onPress: () => {
|
||
Alert.alert('Удалить пост?', 'Это действие нельзя отменить.', [
|
||
{ text: 'Отмена', style: 'cancel' },
|
||
{
|
||
text: 'Удалить',
|
||
style: 'destructive',
|
||
onPress: async () => {
|
||
try {
|
||
await deletePost({
|
||
from: keyFile.pub_key,
|
||
privKey: keyFile.priv_key,
|
||
postID: post.post_id,
|
||
});
|
||
onDeleted?.(post.post_id);
|
||
} catch (e: any) {
|
||
Alert.alert('Ошибка', String(e?.message ?? e));
|
||
}
|
||
},
|
||
},
|
||
]);
|
||
},
|
||
});
|
||
}
|
||
if (options.length === 0) return;
|
||
const buttons: Array<{ text: string; style?: 'default' | 'cancel' | 'destructive'; onPress?: () => void }> = [
|
||
...options.map(o => ({
|
||
text: o.label,
|
||
style: (o.destructive ? 'destructive' : 'default') as 'default' | 'destructive',
|
||
onPress: o.onPress,
|
||
})),
|
||
{ text: 'Отмена', style: 'cancel' as const },
|
||
];
|
||
Alert.alert('Действия', '', buttons);
|
||
}, [keyFile, mine, post.post_id, onDeleted]);
|
||
|
||
// Image URL for attachment preview. We hit the hosting relay directly.
|
||
// For MVP we just show a placeholder — real fetch requires the hosting
|
||
// relay's URL, not just its pubkey. (Future: /api/relays lookup.)
|
||
const attachmentIcon = post.has_attachment;
|
||
|
||
return (
|
||
<Pressable
|
||
onPress={onOpenDetail}
|
||
onLongPress={onLongPress}
|
||
style={({ pressed }) => ({
|
||
flexDirection: 'row',
|
||
paddingHorizontal: 14,
|
||
paddingVertical: compact ? 10 : 12,
|
||
backgroundColor: pressed ? '#080808' : 'transparent',
|
||
borderBottomWidth: 1,
|
||
borderBottomColor: '#141414',
|
||
})}
|
||
>
|
||
{/* Avatar column */}
|
||
<Pressable onPress={onOpenAuthor} hitSlop={4}>
|
||
<Avatar name={displayName} address={post.author} size={44} />
|
||
</Pressable>
|
||
|
||
{/* Content column */}
|
||
<View style={{ flex: 1, marginLeft: 10, minWidth: 0 }}>
|
||
{/* Header: name + time + menu */}
|
||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||
<Pressable onPress={onOpenAuthor} hitSlop={4}>
|
||
<Text
|
||
numberOfLines={1}
|
||
style={{ color: '#ffffff', fontWeight: '700', fontSize: 14, letterSpacing: -0.2 }}
|
||
>
|
||
{displayName}
|
||
</Text>
|
||
</Pressable>
|
||
<Text style={{ color: '#6a6a6a', fontSize: 13, marginHorizontal: 4 }}>·</Text>
|
||
<Text style={{ color: '#6a6a6a', fontSize: 13 }}>
|
||
{formatRelativeTime(post.created_at)}
|
||
</Text>
|
||
<View style={{ flex: 1 }} />
|
||
{mine && (
|
||
<Pressable onPress={onLongPress} hitSlop={8}>
|
||
<Ionicons name="ellipsis-horizontal" size={16} color="#6a6a6a" />
|
||
</Pressable>
|
||
)}
|
||
</View>
|
||
|
||
{/* Body text with hashtag highlighting */}
|
||
{post.content.length > 0 && (
|
||
<Text
|
||
style={{
|
||
color: '#ffffff',
|
||
fontSize: 15,
|
||
lineHeight: 20,
|
||
marginTop: 2,
|
||
}}
|
||
>
|
||
{renderInline(post.content)}
|
||
</Text>
|
||
)}
|
||
|
||
{/* Attachment indicator — real image render requires relay URL */}
|
||
{attachmentIcon && (
|
||
<View
|
||
style={{
|
||
marginTop: 8,
|
||
paddingVertical: 24,
|
||
borderRadius: 14,
|
||
backgroundColor: '#0a0a0a',
|
||
borderWidth: 1,
|
||
borderColor: '#1f1f1f',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
flexDirection: 'row',
|
||
gap: 8,
|
||
}}
|
||
>
|
||
<Ionicons name="image-outline" size={18} color="#5a5a5a" />
|
||
<Text style={{ color: '#5a5a5a', fontSize: 12 }}>
|
||
Открыть пост, чтобы посмотреть вложение
|
||
</Text>
|
||
</View>
|
||
)}
|
||
|
||
{/* Action row */}
|
||
<View
|
||
style={{
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
marginTop: 8,
|
||
gap: 32,
|
||
}}
|
||
>
|
||
<ActionButton
|
||
icon="chatbubble-outline"
|
||
label={formatCount(0) /* replies count — not implemented yet */}
|
||
onPress={onOpenDetail}
|
||
/>
|
||
<Pressable
|
||
onPress={onToggleLike}
|
||
disabled={busy}
|
||
hitSlop={8}
|
||
style={({ pressed }) => ({
|
||
flexDirection: 'row', alignItems: 'center', gap: 6,
|
||
opacity: pressed ? 0.5 : 1,
|
||
})}
|
||
>
|
||
<Animated.View style={{ transform: [{ scale: heartScale }] }}>
|
||
<Ionicons
|
||
name={localLiked ? 'heart' : 'heart-outline'}
|
||
size={16}
|
||
color={localLiked ? '#e0245e' : '#6a6a6a'}
|
||
/>
|
||
</Animated.View>
|
||
<Text
|
||
style={{
|
||
color: localLiked ? '#e0245e' : '#6a6a6a',
|
||
fontSize: 12,
|
||
fontWeight: localLiked ? '600' : '400',
|
||
}}
|
||
>
|
||
{formatCount(localLikeCount)}
|
||
</Text>
|
||
</Pressable>
|
||
<ActionButton
|
||
icon="eye-outline"
|
||
label={formatCount(post.views)}
|
||
/>
|
||
<View style={{ flex: 1 }} />
|
||
<ActionButton
|
||
icon="share-outline"
|
||
onPress={() => {
|
||
// Placeholder — copy postID to clipboard in a future PR.
|
||
Alert.alert('Ссылка', `dchain://post/${post.post_id}`);
|
||
}}
|
||
/>
|
||
</View>
|
||
</View>
|
||
</Pressable>
|
||
);
|
||
}
|
||
|
||
// Silence image import lint since we reference Image type indirectly.
|
||
const _imgKeep = Image;
|
||
|
||
export const PostCard = React.memo(PostCardInner);
|
||
|
||
// ── Inline helpers ──────────────────────────────────────────────────────
|
||
|
||
/** ActionButton — small icon + optional label. */
|
||
function ActionButton({ icon, label, onPress }: {
|
||
icon: React.ComponentProps<typeof Ionicons>['name'];
|
||
label?: string;
|
||
onPress?: () => void;
|
||
}) {
|
||
return (
|
||
<Pressable
|
||
onPress={onPress}
|
||
hitSlop={8}
|
||
disabled={!onPress}
|
||
style={({ pressed }) => ({
|
||
flexDirection: 'row', alignItems: 'center', gap: 6,
|
||
opacity: pressed ? 0.5 : 1,
|
||
})}
|
||
>
|
||
<Ionicons name={icon} size={16} color="#6a6a6a" />
|
||
{label && (
|
||
<Text style={{ color: '#6a6a6a', fontSize: 12 }}>{label}</Text>
|
||
)}
|
||
</Pressable>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Render post body with hashtag highlighting. Splits by the hashtag regex,
|
||
* wraps matches in blue-coloured Text spans that are tappable → hashtag
|
||
* feed. For future: @mentions highlighting + URL auto-linking.
|
||
*/
|
||
function renderInline(text: string): React.ReactNode {
|
||
const parts = text.split(/(#[A-Za-z0-9_\u0400-\u04FF]{1,40})/g);
|
||
return parts.map((part, i) => {
|
||
if (part.startsWith('#')) {
|
||
const tag = part.slice(1);
|
||
return (
|
||
<Text
|
||
key={i}
|
||
style={{ color: '#1d9bf0' }}
|
||
onPress={() => router.push(`/(app)/feed/tag/${encodeURIComponent(tag)}` as never)}
|
||
>
|
||
{part}
|
||
</Text>
|
||
);
|
||
}
|
||
return (
|
||
<Text key={i}>{part}</Text>
|
||
);
|
||
});
|
||
}
|
||
|
||
function shortAddr(a: string, n = 6): string {
|
||
if (!a) return '—';
|
||
return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}…${a.slice(-n)}`;
|
||
}
|
||
|
||
// Keep Image import in play; expo-image lint sometimes trims it.
|
||
void _imgKeep;
|