Feed list padding
FlatList had no inner padding so the first post bumped against the
tab strip and the last post against the NavBar. Added paddingTop: 8
/ paddingBottom: 24 on contentContainerStyle in both /feed and
/feed/tag/[tag] — first card now has a clear top gap, last card
doesn't get hidden behind the FAB or NavBar.
Share-to-chat flow
Replaces the placeholder share button (which showed an Alert with
the post URL) with a real "forward to chats" flow modeled on VK's
shared-wall-post embed.
New modules
lib/forwardPost.ts — encodePostRef / tryParsePostRef +
forwardPostToContacts(). Serialises a
feed post into a tiny JSON payload that
rides the same encrypted envelope as any
chat message; decode side distinguishes
"post_ref" payloads from regular text by
trying JSON.parse on decrypted text.
Mirrors the sent message into the sender's
local history so they see "you shared
this" in the chat they forwarded to.
components/feed/ShareSheet.tsx
— bottom-sheet picker. Multi-select
contacts via tick-box, search by
username / alias / address prefix.
"Send (N)" dispatches N parallel
encrypted envelopes. Contacts with no
X25519 key are filtered out (can't
encrypt for them).
components/chat/PostRefCard.tsx
— compact embedded-post card for chat
bubbles. Ribbon "ПОСТ" label +
author + 3-line excerpt + "с фото"
indicator. Tap → /(app)/feed/{id} full
post detail. Palette switches between
blue-bubble-friendly and peer-bubble-
friendly depending on bubble side.
Message pipeline
lib/types.ts — Message.postRef optional field added.
text stays "" when the message is a
post-ref (nothing to render as plain text).
hooks/useMessages.ts + hooks/useGlobalInbox.ts
— post decryption of every inbound envelope
runs through tryParsePostRef; matching
messages get the postRef attached instead
of the raw JSON in .text.
components/chat/MessageBubble.tsx
— renders PostRefCard inside the bubble when
msg.postRef is set. Other bubble features
(reply quote, attachment preview, text)
still work around it.
PostCard
- share icon now opens <ShareSheet>; the full-URL placeholder is
gone. ShareSheet is embedded at the PostCard level so each card
owns its own sheet state (avoids modal-stacking issues).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
470 lines
16 KiB
TypeScript
470 lines
16 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 (
|
||
<>
|
||
<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={() => setShareOpen(true)}
|
||
/>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
</Pressable>
|
||
<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)}`;
|
||
}
|
||
|