The previous layout put body text, attachments and action row inside
a column next to the avatar. Two recurring bugs came from that:
1. The column's width = screen - 16 - 44 - 10 - 16 = screen - 86px.
Long text or attachments computed against that narrower width,
and on a few RN builds the measurement was off enough that text
visibly ran past the card's right edge.
2. The column visually looked weird: a photo rendered only 3/4 of
the card width because the avatar stole 54px on the left.
Fix: make the card a vertical stack.
┌─────────────────────────────────────────┐
│ [avatar] [name · time] [menu] │ ← HEADER row
├─────────────────────────────────────────┤
│ body text, full card width │ ← content column
│ [attachment image, full card width] │
│ [action row, full card width] │
└─────────────────────────────────────────┘
Now body and media always occupy the full card width (paddingLeft:16
paddingRight:16 from the outer View), long lines wrap inside that,
and the earlier overflow-tricks / width-100% / paddingRight-4
band-aids aren't needed. Removed them.
Header row is unchanged structurally (avatar + name-row Pressable +
menu button) — just lifted into a dedicated View so the content
column below starts at the left card edge instead of alongside the
avatar.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
476 lines
17 KiB
TypeScript
476 lines
17 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 { getNodeUrl } from '@/lib/api';
|
||
import type { FeedPostItem } from '@/lib/feed';
|
||
import {
|
||
formatRelativeTime, formatCount, likePost, unlikePost, deletePost,
|
||
} from '@/lib/feed';
|
||
import { ShareSheet } from '@/components/feed/ShareSheet';
|
||
|
||
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);
|
||
const [shareOpen, setShareOpen] = 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]);
|
||
|
||
// Attachment preview URL — native Image can stream straight from the
|
||
// hosting relay's /feed/post/{id}/attachment endpoint. `getNodeUrl()`
|
||
// returns the node the client is connected to; for cross-relay posts
|
||
// that's actually the hosting relay once /api/relays resolution lands
|
||
// (Phase D). For now we assume same-node.
|
||
const attachmentURL = post.has_attachment
|
||
? `${getNodeUrl()}/feed/post/${post.post_id}/attachment`
|
||
: null;
|
||
|
||
// Body content truncation:
|
||
// - In the timeline (compact=undefined/false) cap at 5 lines. If the
|
||
// text is longer the rest is hidden behind "…" — tapping the card
|
||
// opens the detail view where the full body is shown.
|
||
// - In post detail (compact=true) show everything.
|
||
const bodyLines = compact ? undefined : 5;
|
||
|
||
return (
|
||
<>
|
||
{/* Outer container is a plain View so layout styles (padding, row
|
||
direction) are static and always applied. Pressable's dynamic
|
||
style-function has been observed to drop properties between
|
||
renders on some RN versions — we hit that with the FAB, so
|
||
we're not relying on it here either. Tap handling lives on the
|
||
content-column Pressable (covers ~90% of the card area) plus a
|
||
separate Pressable around the avatar. */}
|
||
{/* Card = vertical stack: [HEADER row with avatar+name+time] /
|
||
[FULL-WIDTH content column with body/image/actions]. Putting
|
||
content under the header (rather than in a column next to the
|
||
avatar) means body text and attachments occupy the full card
|
||
width — no risk of the text running next to the avatar and
|
||
clipping off the right edge. */}
|
||
<View
|
||
style={{
|
||
paddingLeft: 16,
|
||
paddingRight: 16,
|
||
paddingVertical: compact ? 10 : 12,
|
||
}}
|
||
>
|
||
{/* ── HEADER ROW: [avatar] [name · time] [menu] ──────────────── */}
|
||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||
<Pressable onPress={onOpenAuthor} hitSlop={4} style={{ width: 44 }}>
|
||
<Avatar name={displayName} address={post.author} size={44} />
|
||
</Pressable>
|
||
|
||
{/* Name + time take all remaining horizontal space in the
|
||
header, with the name truncating (numberOfLines:1 +
|
||
flexShrink:1) and the "· <time>" tail pinned to stay on
|
||
the same line (flexShrink:0). */}
|
||
<Pressable
|
||
onPress={onOpenAuthor}
|
||
hitSlop={{ top: 4, bottom: 2 }}
|
||
style={{ flex: 1, flexDirection: 'row', alignItems: 'center', marginLeft: 10, minWidth: 0 }}
|
||
>
|
||
<Text
|
||
numberOfLines={1}
|
||
style={{
|
||
color: '#ffffff',
|
||
fontWeight: '700',
|
||
fontSize: 14,
|
||
letterSpacing: -0.2,
|
||
flexShrink: 1,
|
||
}}
|
||
>
|
||
{displayName}
|
||
</Text>
|
||
<Text
|
||
numberOfLines={1}
|
||
style={{
|
||
color: '#6a6a6a',
|
||
fontSize: 13,
|
||
marginLeft: 6,
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
· {formatRelativeTime(post.created_at)}
|
||
</Text>
|
||
</Pressable>
|
||
|
||
{mine && (
|
||
<Pressable onPress={onLongPress} hitSlop={8} style={{ marginLeft: 8 }}>
|
||
<Ionicons name="ellipsis-horizontal" size={16} color="#6a6a6a" />
|
||
</Pressable>
|
||
)}
|
||
</View>
|
||
|
||
{/* ── CONTENT (body, attachment, actions) — full card width ──── */}
|
||
<Pressable
|
||
onPress={onOpenDetail}
|
||
onLongPress={onLongPress}
|
||
style={({ pressed }) => ({
|
||
marginTop: 8,
|
||
overflow: 'hidden',
|
||
opacity: pressed ? 0.85 : 1,
|
||
})}
|
||
>
|
||
|
||
{/* Body text with hashtag highlighting. Full card width now
|
||
(we moved it out of the avatar-sibling column) — no special
|
||
width tricks needed, normal wrapping just works. */}
|
||
{post.content.length > 0 && (
|
||
<Text
|
||
numberOfLines={bodyLines}
|
||
ellipsizeMode="tail"
|
||
style={{
|
||
color: '#ffffff',
|
||
fontSize: 15,
|
||
lineHeight: 20,
|
||
}}
|
||
>
|
||
{renderInline(post.content)}
|
||
</Text>
|
||
)}
|
||
|
||
{/* Attachment preview.
|
||
Timeline (compact=false): aspect-ratio capped at 4:5 so a
|
||
portrait photo doesn't occupy the whole screen — extra height
|
||
is cropped via resizeMode="cover", full image shows in detail.
|
||
Detail (compact=true): contain with no aspect cap → original
|
||
proportions preserved. */}
|
||
{attachmentURL && (
|
||
<View
|
||
style={{
|
||
marginTop: 10,
|
||
borderRadius: 14,
|
||
overflow: 'hidden',
|
||
backgroundColor: '#0a0a0a',
|
||
borderWidth: 1,
|
||
borderColor: '#1f1f1f',
|
||
}}
|
||
>
|
||
<Image
|
||
source={{ uri: attachmentURL }}
|
||
style={compact ? {
|
||
width: '100%',
|
||
aspectRatio: 1, // will be overridden by onLoad if known
|
||
maxHeight: undefined,
|
||
} : {
|
||
width: '100%',
|
||
aspectRatio: 4 / 5, // portrait-friendly but bounded
|
||
}}
|
||
resizeMode={compact ? 'contain' : 'cover'}
|
||
/>
|
||
</View>
|
||
)}
|
||
|
||
{/* Action row — 3 buttons (Twitter-style). Comments button
|
||
intentionally omitted: v2.0.0 doesn't implement replies and
|
||
a dead button with "0" adds noise. Heart + views + share
|
||
distribute across the row; share pins to the right edge. */}
|
||
<View
|
||
style={{
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
marginTop: 12,
|
||
paddingHorizontal: 12,
|
||
}}
|
||
>
|
||
<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',
|
||
lineHeight: 16,
|
||
includeFontPadding: false,
|
||
textAlignVertical: 'center',
|
||
}}
|
||
>
|
||
{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={() => setShareOpen(true)}
|
||
/>
|
||
</View>
|
||
</View>
|
||
</Pressable>
|
||
</View>
|
||
<ShareSheet
|
||
visible={shareOpen}
|
||
post={post}
|
||
onClose={() => setShareOpen(false)}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
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,
|
||
// Match icon height (16) as lineHeight so baseline-anchored
|
||
// text aligns with the icon's vertical centre. Without this,
|
||
// RN renders Text one pixel lower than the icon mid-point on
|
||
// most Android fonts, which looks sloppy next to the heart /
|
||
// eye glyphs.
|
||
lineHeight: 16,
|
||
includeFontPadding: false,
|
||
textAlignVertical: 'center',
|
||
}}
|
||
>
|
||
{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)}`;
|
||
}
|
||
|