- Post divider was on each PostCard's outer Pressable as borderBottom (#222), which was barely visible on OLED black and disappeared entirely in pressed state (the pressed bg ate the line). Moved the seam to a dedicated PostSeparator component (1px, #2a2a2a) wired as FlatList's ItemSeparatorComponent on both /feed (timeline / for-you / trending) and /feed/tag/[tag]. Also bumped inter-card vertical padding (14-16 top / 16-20 bottom) so cards have real breathing room even before the divider. - FAB position was flaky: with <Stack> at the (app) level the overlay could end up positioned against the Stack's card view instead of the tab container, which made the button drift around and stick against unexpected edges. Wrapped it in an absoluteFill container with pointerEvents="box-none" — the wrapper owns positioning against the tab screen, the button inside just declares right: 14 / bottom: N. Bumped bottom offset to `max(insets.bottom, 8) + 70` so the FAB always clears the 5-icon NavBar with ~14px visual gap on every device. Shadow switched from blue-cast to standard dark for better depth perception on dark backgrounds. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
395 lines
14 KiB
TypeScript
395 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,
|
||
paddingTop: compact ? 14 : 18,
|
||
paddingBottom: compact ? 16 : 20,
|
||
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. */}
|
||
<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);
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
* The line colour (#2a2a2a) is the minimum grey that reads on OLED
|
||
* black under mobile-bright-mode gamma — go darker and the seam vanishes.
|
||
* Height 1 is one logical px (hairline on retina). No horizontal inset:
|
||
* Twitter runs the seam edge-to-edge and it looks cleaner than a gap.
|
||
*/
|
||
export function PostSeparator() {
|
||
return <View style={{ height: 1, backgroundColor: '#2a2a2a' }} />;
|
||
}
|
||
|
||
// ── 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;
|