Files
dchain/client-app/components/feed/PostCard.tsx
vsecoder 51bc0a1850 fix(feed): card spacing, action-row distribution, tab strip, detail inset
- PostCard rows got cramped paddings and a near-invisible divider.
  Increased paddingTop 12→16, paddingBottom 12→18, paddingHorizontal
  14→16; divider colour #141414→#222222 so the seam between posts is
  legible on OLED blacks.
- Action row (chat / ❤ / view / share) used a fixed gap:32 + spacer.
  Reworked to four flex:1 cells with justifyContent: space-between,
  so the first three icons distribute evenly across the row and share
  pins to the right edge. Matches Twitter's layout where each action
  occupies a quarter of the row regardless of label width.
- Feed tab strip (Подписки / Для вас / В тренде) used flex:1 +
  gap:10 which bunched the three labels together visually. Switched
  to justifyContent: space-between + paddingHorizontal:20 so each
  tab hugs its label and the three labels spread to the edges with
  full horizontal breathing room.
- Post detail screen (/feed/[id]) and hashtag feed (/feed/tag/[tag])
  were missing the safe-area top inset — their headers butted right
  against the status bar / notch. Added useSafeAreaInsets().top as
  paddingTop on the outer View, matching the rest of the app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 20:20:18 +03:00

383 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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: 16,
paddingTop: compact ? 12 : 16,
paddingBottom: compact ? 14 : 18,
backgroundColor: pressed ? '#080808' : 'transparent',
borderBottomWidth: 1,
borderBottomColor: '#222222',
})}
>
{/* 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 — 4 evenly-spaced buttons (Twitter-style). Each is
wrapped in a flex: 1 container so even if one label is
wider than another, visual spacing between centres stays
balanced. */}
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 12,
paddingRight: 4,
}}
>
<View style={{ flex: 1, alignItems: 'flex-start' }}>
<ActionButton
icon="chatbubble-outline"
label={formatCount(0) /* replies count — not implemented yet */}
onPress={onOpenDetail}
/>
</View>
<View style={{ flex: 1, alignItems: 'flex-start' }}>
<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>
</View>
<View style={{ flex: 1, alignItems: 'flex-start' }}>
<ActionButton
icon="eye-outline"
label={formatCount(post.views)}
/>
</View>
<View style={{ alignItems: 'flex-end' }}>
<ActionButton
icon="share-outline"
onPress={() => {
// Placeholder — copy postID to clipboard in a future PR.
Alert.alert('Ссылка', `dchain://post/${post.post_id}`);
}}
/>
</View>
</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;