Node flags (cmd/node/main.go):
--max-cpu / --max-ram-mb — Go runtime caps (GOMAXPROCS / GOMEMLIMIT)
--feed-disk-limit-mb — hard 507 refusal for new post bodies over quota
--chain-disk-limit-mb — advisory watcher (can't reject blocks without
breaking consensus; logs WARN every minute)
Client — Saved Messages (self-chat):
- Auto-created on sign-in, pinned top of chat list, blue bookmark avatar
- Send short-circuits the relay (no encrypt, no fee, no mailbox hop)
- Empty state rendered outside inverted FlatList — fixes the mirrored
"say hi…" on Android RTL-aware layout builds
- PostCard shows "You" for own posts instead of the self-contact alias
Client — user walls:
- New route /(app)/feed/author/[pub] with infinite-scroll via
`created_at` cursor and pull-to-refresh
- Profile screen gains "View posts" button (universal) next to
"Open chat" (contact-only)
Feed pipeline:
- Bump client JPEG quality 0.5 → 0.75 to match server scrubber (Q=75),
so a 60 KiB compose doesn't balloon past 256 KiB after server re-encode
- ErrPostTooLarge now wraps with the actual size vs cap, errors.Is
preserved in the HTTP layer
- FeedMailbox quota + DiskUsage surface — supports new CLI flag
README:
- Step-by-step "first node / joiner" section on the landing page,
full flag tables incl. the new resource-cap group, minimal
checklists for open/private/low-end deployments
481 lines
17 KiB
TypeScript
481 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.
|
|
//
|
|
// `mine` takes precedence over the contact lookup: our own pub key has
|
|
// a self-contact entry with alias "Saved Messages" (that's how the
|
|
// self-chat tile is rendered), but that label is wrong in the feed —
|
|
// posts there should read as "You", not as a messaging-app affordance.
|
|
const displayName = useMemo(() => {
|
|
if (mine) return 'You';
|
|
const c = contacts.find(x => x.address === post.author);
|
|
if (c?.username) return `@${c.username}`;
|
|
if (c?.alias) return c.alias;
|
|
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('Failed', 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: 'Delete post',
|
|
destructive: true,
|
|
onPress: () => {
|
|
Alert.alert('Delete post?', 'This action cannot be undone.', [
|
|
{ text: 'Cancel', style: 'cancel' },
|
|
{
|
|
text: 'Delete',
|
|
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('Error', 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: 'Cancel', style: 'cancel' as const },
|
|
];
|
|
Alert.alert('Actions', '', 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: 14,
|
|
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)}`;
|
|
}
|
|
|