- Post action row (chat / ❤ / eye / share) had only a small paddingRight and no left padding. First icon sat flush under the avatar and share iron against the card edge. Replaced with paddingHorizontal: 12 so both sides get equal breathing room; each of the four cells still flex:1 so the icons distribute evenly. - FAB kept appearing at the LEFT edge instead of the right on user's device despite position:absolute + right:12. Pressable's dynamic- function style can drop absolute-positioning fields between renders on some RN versions. Wrapping the Pressable in a plain absolute- positioned View fixes this: positioning lives on the View (never re-evaluated mid-render), the Pressable inside only declares size and visuals. pointerEvents="box-none" on the wrapper keeps taps outside the button passing through to the feed list below. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
403 lines
14 KiB
TypeScript
403 lines
14 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: 16,
|
||
paddingVertical: compact ? 10 : 12,
|
||
backgroundColor: pressed ? '#080808' : 'transparent',
|
||
})}
|
||
>
|
||
{/* 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. paddingHorizontal gives extra breathing room on
|
||
both sides so the first icon isn't flush under the avatar
|
||
and the share icon isn't flush with the card edge. */}
|
||
<View
|
||
style={{
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
marginTop: 12,
|
||
paddingHorizontal: 12,
|
||
}}
|
||
>
|
||
<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);
|
||
|
||
/**
|
||
* PostSeparator — visible divider line between post cards. Exported so
|
||
* every feed surface (timeline, author, hashtag, post detail) can pass
|
||
* it as ItemSeparatorComponent and get identical spacing / colour.
|
||
*
|
||
* Layout: 12px blank space → 1px grey line → 12px blank space. The
|
||
* blank space on each side makes the line "float" between posts rather
|
||
* than hugging the edge of the card — gives clear visual separation
|
||
* without needing big card padding everywhere.
|
||
*
|
||
* Colour #2a2a2a is the minimum grey that reads on OLED black under
|
||
* mobile-bright-mode gamma; darker and the seam vanishes.
|
||
*/
|
||
export function PostSeparator() {
|
||
return (
|
||
<View style={{ paddingVertical: 12 }}>
|
||
<View style={{ height: 1, backgroundColor: '#2a2a2a' }} />
|
||
</View>
|
||
);
|
||
}
|
||
|
||
// ── 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;
|