Files
dchain/client-app/components/feed/PostCard.tsx
vsecoder 50aacced0b fix(feed): header on one line, avatar padding explicit, icon/label aligned
Three related layout polish issues.

1. Avatar + name + time splitting to two lines

   Fixed by collapsing the "·" separator + time into a single Text
   component (was three sibling Texts). RN occasionally lays out
   three-Text-sibling rows oddly when the name is long because each
   Text measures independently. With the separator glued to the time,
   there's one indivisible chip for "·  2h" that flexShrink:0 keeps on
   the row; the name takes the rest and truncates with "…" if needed.
   Also wrapped the name + time in a flex:1 Pressable (author tap
   target) so the row width is authoritative.

2. Avatar flush against the left edge

   The outer Pressable's style-function paddingLeft was being applied
   but the visual offset felt wrong because avatar has no explicit
   width. Added width:44 to the avatar's own Pressable wrapper so the
   flex-row layout reserves exactly the expected space and the 16-px
   paddingLeft on the outer Pressable is visible around the avatar's
   left edge.

3. Like / view count text visually below icon's centre

   RN Text includes extra top padding (Android) and baseline-anchors
   (iOS) that push the number glyph a pixel or two below the icon
   centre in a flex-row with alignItems:'center'. Added explicit
   lineHeight:16 (matching icon size) + includeFontPadding:false +
   textAlignVertical:'center' on both the heart button's Text and the
   shared ActionButton Text → numbers now sit on the same optical
   baseline as their icons on both platforms.

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

464 lines
16 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 { getNodeUrl } from '@/lib/api';
import type { FeedPostItem } from '@/lib/feed';
import {
formatRelativeTime, formatCount, likePost, unlikePost, deletePost,
} 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]);
// 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 (
<Pressable
onPress={onOpenDetail}
onLongPress={onLongPress}
style={({ pressed }) => ({
flexDirection: 'row',
paddingLeft: 16,
paddingRight: 16,
paddingVertical: compact ? 10 : 12,
backgroundColor: pressed ? '#080808' : 'transparent',
})}
>
{/* Avatar — own tap target (opens author profile). Explicit width
on the wrapper (width:44) so the flex-row sibling below computes
its remaining space correctly. */}
<Pressable onPress={onOpenAuthor} hitSlop={4} style={{ width: 44 }}>
<Avatar name={displayName} address={post.author} size={44} />
</Pressable>
{/* Content column. overflow:'hidden' prevents unbreakable tokens
from drawing past the right edge of the card. */}
<View style={{ flex: 1, marginLeft: 10, minWidth: 0, overflow: 'hidden' }}>
{/* Header row — name + time on ONE line.
Two siblings: the author-link Pressable (flex:1, row, so it
expands; name inside gets numberOfLines:1 + flexShrink:1 so
it truncates instead of wrapping) and the "· <time>" tail
(flexShrink:0 — never truncates). Keeping the "·" inside the
time Text means they stay glued even if the inline layout
decides to break weirdly. */}
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Pressable
onPress={onOpenAuthor}
hitSlop={{ top: 4, bottom: 2 }}
style={{ flex: 1, flexDirection: 'row', alignItems: 'center', 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>
{/* Body text with hashtag highlighting.
flexShrink:1 + explicit width:'100%' + paddingRight:4 keep
long lines inside the content column on every platform. On
Android a few RN versions have been known to let the inner
Text spans overflow the parent by 1-2 px without an explicit
width declaration — hence the belt-and-braces here. */}
{post.content.length > 0 && (
<Text
numberOfLines={bodyLines}
ellipsizeMode="tail"
style={{
color: '#ffffff',
fontSize: 15,
lineHeight: 20,
marginTop: 2,
width: '100%',
flexShrink: 1,
paddingRight: 4,
}}
>
{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={() => {
// Placeholder — copy postID to clipboard in a future PR.
Alert.alert('Ссылка', `dchain://post/${post.post_id}`);
}}
/>
</View>
</View>
</View>
</Pressable>
);
}
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)}`;
}