diff --git a/client-app/app/(app)/feed/[id].tsx b/client-app/app/(app)/feed/[id].tsx index c66dbf1..355baa9 100644 --- a/client-app/app/(app)/feed/[id].tsx +++ b/client-app/app/(app)/feed/[id].tsx @@ -17,7 +17,7 @@ */ import React, { useCallback, useEffect, useState } from 'react'; import { - View, Text, ScrollView, ActivityIndicator, Image, + View, Text, ScrollView, ActivityIndicator, } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { router, useLocalSearchParams } from 'expo-router'; @@ -99,21 +99,17 @@ export default function PostDetailScreen() { ) : ( + {/* `compact` tells PostCard to drop the 5-line body cap and + render the attachment at its natural aspect ratio instead + of the portrait-cropped timeline preview. */} - {/* Attachment preview (if any). For MVP we try loading from the - CURRENT node — works when you're connected to the hosting - relay. Cross-relay discovery (look up hosting_relay URL via - /api/relays) is future work. */} - {post.has_attachment && ( - - )} - {/* Detailed stats block */} - - - Вложение: {url} - - - Прямой просмотр вложений — в следующем релизе - - - ); -} - function shortAddr(a: string, n = 6): string { if (!a) return '—'; return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}…${a.slice(-n)}`; } - -// Silence Image import when unused (reserved for future attachment preview). -void Image; diff --git a/client-app/components/feed/PostCard.tsx b/client-app/components/feed/PostCard.tsx index 35fc26c..b823833 100644 --- a/client-app/components/feed/PostCard.tsx +++ b/client-app/components/feed/PostCard.tsx @@ -30,9 +30,10 @@ 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, fetchStats, + formatRelativeTime, formatCount, likePost, unlikePost, deletePost, } from '@/lib/feed'; export interface PostCardProps { @@ -162,10 +163,21 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }: 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; + // 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 ( - {/* Header: name + time + menu */} - - - - {displayName} - - - · - + {/* Header: [name] · [time] … [menu] + All three on a single row with no wrap. The name shrinks if + too long (flexShrink:1 + numberOfLines:1), time never shrinks + — so long handles get truncated with ellipsis while "2h" + stays readable. + + Whole header is inside a single Pressable so a tap anywhere + on the header opens the author's profile — matches Twitter's + behaviour where the name-row is a big hit target. */} + + + {displayName} + + + · + + {formatRelativeTime(post.created_at)} @@ -205,11 +241,13 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }: )} - + {/* Body text with hashtag highlighting */} {post.content.length > 0 && ( )} - {/* Attachment indicator — real image render requires relay URL */} - {attachmentIcon && ( + {/* 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 && ( - - - Открыть пост, чтобы посмотреть вложение - + )} - {/* 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. paddingHorizontal gives extra breathing room on - both sides so the first icon isn't flush under the avatar - and the share icon isn't flush with the card edge. */} + {/* 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. */} - - - . +// +// Why a dedicated endpoint? The /feed/post/{id} response wraps the body +// as base64 inside JSON; fetching that + decoding for N posts in a feed +// list would blow up memory. Native image loaders stream bytes straight +// to the GPU — this route lets them do that without intermediate JSON. +// +// Respects on-chain soft-delete: returns 410 when the post is tombstoned. +func feedPostAttachment(cfg FeedConfig) postHandler { + return func(w http.ResponseWriter, r *http.Request, postID string) { + if r.Method != http.MethodGet { + jsonErr(w, fmt.Errorf("method not allowed"), 405) + return + } + if cfg.GetPost != nil { + if rec, _ := cfg.GetPost(postID); rec != nil && rec.Deleted { + jsonErr(w, fmt.Errorf("post %s deleted", postID), 410) + return + } + } + post, err := cfg.Mailbox.Get(postID) + if err != nil { + jsonErr(w, err, 500) + return + } + if post == nil || len(post.Attachment) == 0 { + jsonErr(w, fmt.Errorf("no attachment for post %s", postID), 404) + return + } + mime := post.AttachmentMIME + if mime == "" { + mime = "application/octet-stream" + } + w.Header().Set("Content-Type", mime) + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(post.Attachment))) + // Cache for 1 hour — attachments are immutable (tied to content_hash), + // so aggressive client-side caching is safe and saves bandwidth. + w.Header().Set("Cache-Control", "public, max-age=3600, immutable") + w.Header().Set("ETag", `"`+postID+`"`) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(post.Attachment) + } +} + type postHandler func(w http.ResponseWriter, r *http.Request, postID string) func feedGetPost(cfg FeedConfig) postHandler {